From 5c749d98edcb406d2d6d14ee0737fd721e44ff30 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 12:03:44 -0400 Subject: [PATCH 01/54] [Lens] Unify invalid state styling on the dimension button (#114825) (#115365) * :lipstick: Sync the invalid state on the dimension button * :label: Fix type issue Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Marco Liberati --- .../editor_frame/config_panel/buttons/dimension_button.tsx | 3 +++ .../editor_frame/config_panel/layer_panel.tsx | 7 +++++++ .../lens/public/indexpattern_datasource/indexpattern.tsx | 7 ++++++- x-pack/plugins/lens/public/mocks.tsx | 1 + x-pack/plugins/lens/public/types.ts | 4 ++++ 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/dimension_button.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/dimension_button.tsx index 5d9fd1f8b8f13..508148be8b2a9 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/dimension_button.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/buttons/dimension_button.tsx @@ -25,6 +25,7 @@ export function DimensionButton({ onRemoveClick, accessorConfig, label, + invalid, }: { group: VisualizationDimensionGroupConfig; children: React.ReactElement; @@ -32,6 +33,7 @@ export function DimensionButton({ onRemoveClick: (id: string) => void; accessorConfig: AccessorConfig; label: string; + invalid?: boolean; }) { return ( <> @@ -41,6 +43,7 @@ export function DimensionButton({ onClick={() => onClick(accessorConfig.columnId)} aria-label={triggerLinkA11yText(label)} title={triggerLinkA11yText(label)} + color={invalid || group.invalid ? 'danger' : undefined} > {children} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 8d19620cebbdc..bdd5d93c2c2c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -477,6 +477,13 @@ export function LayerPanel( ); removeButtonRef(id); }} + invalid={ + !layerDatasource.isValidColumn( + layerDatasourceState, + layerId, + columnId + ) + } > { + const layer = state.layers[layerId]; + return !isColumnInvalid(layer, columnId, state.indexPatterns[layer.indexPatternId]); + }, + renderDimensionTrigger: ( domElement: Element, props: DatasourceDimensionTriggerProps diff --git a/x-pack/plugins/lens/public/mocks.tsx b/x-pack/plugins/lens/public/mocks.tsx index 989c858b1f29d..5c285f70b2ed9 100644 --- a/x-pack/plugins/lens/public/mocks.tsx +++ b/x-pack/plugins/lens/public/mocks.tsx @@ -159,6 +159,7 @@ export function createMockDatasource(id: string): DatasourceMock { getErrorMessages: jest.fn((_state) => undefined), checkIntegrity: jest.fn((_state) => []), isTimeBased: jest.fn(), + isValidColumn: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 87e2762149acd..e207f2938dd3c 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -281,6 +281,10 @@ export interface Datasource { * Checks if the visualization created is time based, for example date histogram */ isTimeBased: (state: T) => boolean; + /** + * Given the current state layer and a columnId will verify if the column configuration has errors + */ + isValidColumn: (state: T, layerId: string, columnId: string) => boolean; } export interface DatasourceFixAction { From a6e9d8ff34c827f0f393a82e359035247cb3b813 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 18 Oct 2021 18:45:14 +0200 Subject: [PATCH 02/54] [Uptime] Added uptime query inspector panel (#115170) (#115376) --- .../scripts/upload-telemetry-data/index.ts | 3 +- .../utils/get_inspect_response.ts | 49 ++++++++++++------- .../utils/unwrap_es_response.ts | 0 .../series_editor/use_filter_values.ts | 6 ++- .../shared/field_value_suggestions/index.tsx | 3 ++ .../shared/field_value_suggestions/types.ts | 2 + .../context/inspector/inspector_context.tsx | 7 +++ .../public/hooks/use_es_search.ts | 46 ++++++++++++++--- .../public/hooks/use_values_list.ts | 7 ++- x-pack/plugins/observability/public/index.ts | 5 +- x-pack/plugins/observability/server/index.ts | 7 ++- .../annotations/create_annotations_client.ts | 2 +- x-pack/plugins/uptime/kibana.json | 1 + x-pack/plugins/uptime/public/apps/plugin.ts | 2 + .../plugins/uptime/public/apps/uptime_app.tsx | 10 ++-- .../public/apps/uptime_page_template.tsx | 8 ++- .../certificates/use_cert_search.ts | 16 +++--- .../common/header/action_menu_content.tsx | 2 + .../common/header/inspector_header_link.tsx | 39 +++++++++++++++ .../components/monitor/ml/ml_flyout.test.tsx | 3 ++ .../use_step_waterfall_metrics.test.tsx | 3 +- .../step_detail/use_step_waterfall_metrics.ts | 5 +- .../overview/filter_group/filter_group.tsx | 9 +++- .../monitor_list/use_monitor_histogram.ts | 11 +++-- x-pack/plugins/uptime/public/routes.tsx | 6 +++ .../uptime/public/state/api/snapshot.test.ts | 1 + .../plugins/uptime/public/state/api/utils.ts | 18 +++++-- x-pack/plugins/uptime/server/lib/lib.ts | 30 +++++++++++- .../server/lib/requests/get_ping_histogram.ts | 2 +- .../lib/requests/get_snapshot_counts.ts | 9 ++-- .../requests/search/find_potential_matches.ts | 2 +- .../lib/requests/search/query_context.ts | 4 +- .../search/refine_potential_matches.ts | 2 +- .../rest_api/index_state/get_index_status.ts | 7 +-- .../server/rest_api/monitors/monitor_list.ts | 1 - .../rest_api/monitors/monitor_locations.ts | 1 - .../rest_api/monitors/monitor_status.ts | 1 - .../rest_api/monitors/monitors_details.ts | 1 - .../rest_api/monitors/monitors_durations.ts | 1 - .../rest_api/pings/get_ping_histogram.ts | 1 - .../uptime/server/rest_api/pings/get_pings.ts | 1 - .../pings/journey_screenshot_blocks.ts | 3 -- .../rest_api/pings/journey_screenshots.ts | 4 -- .../uptime/server/rest_api/pings/journeys.ts | 2 - .../rest_api/snapshot/get_snapshot_count.ts | 1 - .../synthetics/last_successful_step.ts | 1 - .../rest_api/telemetry/log_page_view.ts | 1 - .../server/rest_api/uptime_route_wrapper.ts | 11 ++++- 48 files changed, 266 insertions(+), 91 deletions(-) rename x-pack/plugins/observability/{server => common}/utils/get_inspect_response.ts (79%) rename x-pack/plugins/observability/{server => common}/utils/unwrap_es_response.ts (100%) create mode 100644 x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx diff --git a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts index c900123c6cee9..6397c79ce4ffb 100644 --- a/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts +++ b/x-pack/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -17,8 +17,7 @@ import { argv } from 'yargs'; import { Logger } from 'kibana/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { CollectTelemetryParams } from '../../server/lib/apm_telemetry/collect_data_telemetry'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { unwrapEsResponse } from '../../../observability/server/utils/unwrap_es_response'; +import { unwrapEsResponse } from '../../../observability/common/utils/unwrap_es_response'; import { downloadTelemetryTemplate } from '../shared/download-telemetry-template'; import { mergeApmTelemetryMapping } from '../../common/apm_telemetry'; import { generateSampleDocuments } from './generate-sample-documents'; diff --git a/x-pack/plugins/observability/server/utils/get_inspect_response.ts b/x-pack/plugins/observability/common/utils/get_inspect_response.ts similarity index 79% rename from x-pack/plugins/observability/server/utils/get_inspect_response.ts rename to x-pack/plugins/observability/common/utils/get_inspect_response.ts index a6792e0cac5fd..55d84b622dc2c 100644 --- a/x-pack/plugins/observability/server/utils/get_inspect_response.ts +++ b/x-pack/plugins/observability/common/utils/get_inspect_response.ts @@ -8,8 +8,8 @@ import { i18n } from '@kbn/i18n'; import type { KibanaRequest } from 'kibana/server'; import type { RequestStatistics, RequestStatus } from '../../../../../src/plugins/inspector'; -import { WrappedElasticsearchClientError } from '../index'; import { InspectResponse } from '../../typings/common'; +import { WrappedElasticsearchClientError } from './unwrap_es_response'; /** * Get statistics to show on inspector tab. @@ -29,19 +29,26 @@ function getStats({ kibanaRequest: KibanaRequest; }) { const stats: RequestStatistics = { - kibanaApiQueryParameters: { - label: i18n.translate('xpack.observability.inspector.stats.kibanaApiQueryParametersLabel', { - defaultMessage: 'Kibana API query parameters', - }), - description: i18n.translate( - 'xpack.observability.inspector.stats.kibanaApiQueryParametersDescription', - { - defaultMessage: - 'The query parameters used in the Kibana API request that initiated the Elasticsearch request.', + ...(kibanaRequest.query + ? { + kibanaApiQueryParameters: { + label: i18n.translate( + 'xpack.observability.inspector.stats.kibanaApiQueryParametersLabel', + { + defaultMessage: 'Kibana API query parameters', + } + ), + description: i18n.translate( + 'xpack.observability.inspector.stats.kibanaApiQueryParametersDescription', + { + defaultMessage: + 'The query parameters used in the Kibana API request that initiated the Elasticsearch request.', + } + ), + value: JSON.stringify(kibanaRequest.query, null, 2), + }, } - ), - value: JSON.stringify(kibanaRequest.query, null, 2), - }, + : {}), kibanaApiRoute: { label: i18n.translate('xpack.observability.inspector.stats.kibanaApiRouteLabel', { defaultMessage: 'Kibana API route', @@ -93,11 +100,17 @@ function getStats({ } if (esResponse?.hits?.total !== undefined) { - const total = esResponse.hits.total as { - relation: string; - value: number; - }; - const hitsTotalValue = total.relation === 'eq' ? `${total.value}` : `> ${total.value}`; + let hitsTotalValue; + + if (typeof esResponse.hits.total === 'number') { + hitsTotalValue = esResponse.hits.total; + } else { + const total = esResponse.hits.total as { + relation: string; + value: number; + }; + hitsTotalValue = total.relation === 'eq' ? `${total.value}` : `> ${total.value}`; + } stats.hitsTotal = { label: i18n.translate('xpack.observability.inspector.stats.hitsTotalLabel', { diff --git a/x-pack/plugins/observability/server/utils/unwrap_es_response.ts b/x-pack/plugins/observability/common/utils/unwrap_es_response.ts similarity index 100% rename from x-pack/plugins/observability/server/utils/unwrap_es_response.ts rename to x-pack/plugins/observability/common/utils/unwrap_es_response.ts diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts index 8c659db559d68..e84f79f88298c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/use_filter_values.ts @@ -12,7 +12,10 @@ import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../src/core/types/elasticsearch'; import { PersistableFilter } from '../../../../../../lens/common'; -export function useFilterValues({ field, series, baseFilters }: FilterProps, query?: string) { +export function useFilterValues( + { field, series, baseFilters, label }: FilterProps, + query?: string +) { const { indexPatterns } = useAppIndexPatternContext(series.dataType); const queryFilters: ESFilter[] = []; @@ -28,6 +31,7 @@ export function useFilterValues({ field, series, baseFilters }: FilterProps, que return useValuesList({ query, + label, sourceField: field, time: series.time, keepHistory: true, diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index 1c5da15dd33df..ccb2ea2932f5d 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -33,6 +33,7 @@ export function FieldValueSuggestions({ required, allowExclusions = true, cardinalityField, + inspector, asCombobox = true, onChange: onSelectionChange, }: FieldValueSuggestionsProps) { @@ -44,7 +45,9 @@ export function FieldValueSuggestions({ sourceField, filters, time, + inspector, cardinalityField, + label, keepHistory: true, }); 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 b6de2bafdd852..6f6d520a83154 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 @@ -8,6 +8,7 @@ import { PopoverAnchorPosition } from '@elastic/eui'; import { Dispatch, SetStateAction } from 'react'; import { ESFilter } from 'src/core/types/elasticsearch'; +import { IInspectorInfo } from '../../../../../../../src/plugins/data/common'; interface CommonProps { selectedValue?: string[]; @@ -37,6 +38,7 @@ export type FieldValueSuggestionsProps = CommonProps & { onChange: (val?: string[], excludedValue?: string[]) => void; filters: ESFilter[]; time?: { from: string; to: string }; + inspector?: IInspectorInfo; }; export type FieldValueSelectionProps = CommonProps & { diff --git a/x-pack/plugins/observability/public/context/inspector/inspector_context.tsx b/x-pack/plugins/observability/public/context/inspector/inspector_context.tsx index 1d9bd95fa08fa..56498fcaecd5c 100644 --- a/x-pack/plugins/observability/public/context/inspector/inspector_context.tsx +++ b/x-pack/plugins/observability/public/context/inspector/inspector_context.tsx @@ -23,6 +23,13 @@ const value: InspectorContextValue = { export const InspectorContext = createContext(value); +export type AddInspectorRequest = ( + result: FetcherResult<{ + mainStatisticsData?: { _inspect?: InspectResponse }; + _inspect?: InspectResponse; + }> +) => void; + export function InspectorContextProvider({ children }: { children: ReactNode }) { const history = useHistory(); const { inspectorAdapters } = value; diff --git a/x-pack/plugins/observability/public/hooks/use_es_search.ts b/x-pack/plugins/observability/public/hooks/use_es_search.ts index 70ffdbba22c58..bf96cf2c1f2c5 100644 --- a/x-pack/plugins/observability/public/hooks/use_es_search.ts +++ b/x-pack/plugins/observability/public/hooks/use_es_search.ts @@ -9,27 +9,61 @@ import { estypes } from '@elastic/elasticsearch'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { ESSearchResponse } from '../../../../../src/core/types/elasticsearch'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; -import { isCompleteResponse } from '../../../../../src/plugins/data/common'; -import { useFetcher } from './use_fetcher'; +import { IInspectorInfo, isCompleteResponse } from '../../../../../src/plugins/data/common'; +import { FETCH_STATUS, useFetcher } from './use_fetcher'; +import { useInspectorContext } from '../context/inspector/use_inspector_context'; +import { getInspectResponse } from '../../common/utils/get_inspect_response'; export const useEsSearch = ( params: TParams, - fnDeps: any[] + fnDeps: any[], + options: { inspector?: IInspectorInfo; name: string } ) => { const { services: { data }, } = useKibana<{ data: DataPublicPluginStart }>(); + const { name } = options ?? {}; + + const { addInspectorRequest } = useInspectorContext(); + const { data: response = {}, loading } = useFetcher(() => { if (params.index) { + const startTime = Date.now(); return new Promise((resolve) => { const search$ = data.search - .search({ - params, - }) + .search( + { + params, + }, + {} + ) .subscribe({ next: (result) => { if (isCompleteResponse(result)) { + if (addInspectorRequest) { + addInspectorRequest({ + data: { + _inspect: [ + getInspectResponse({ + startTime, + esRequestParams: params, + esResponse: result.rawResponse, + esError: null, + esRequestStatus: 1, + operationName: name, + kibanaRequest: { + route: { + path: '/internal/bsearch', + method: 'POST', + }, + } as any, + }), + ], + }, + status: FETCH_STATUS.SUCCESS, + }); + } // Final result resolve(result); search$.unsubscribe(); diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index 7f52fff55e706..e2268f7b85244 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -10,16 +10,19 @@ import { useEffect, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { ESFilter } from '../../../../../src/core/types/elasticsearch'; import { createEsParams, useEsSearch } from './use_es_search'; +import { IInspectorInfo } from '../../../../../src/plugins/data/common'; import { TRANSACTION_URL } from '../components/shared/exploratory_view/configurations/constants/elasticsearch_fieldnames'; export interface Props { sourceField: string; + label: string; query?: string; indexPatternTitle?: string; filters?: ESFilter[]; time?: { from: string; to: string }; keepHistory?: boolean; cardinalityField?: string; + inspector?: IInspectorInfo; } export interface ListItem { @@ -60,6 +63,7 @@ export const useValuesList = ({ query = '', filters, time, + label, keepHistory, cardinalityField, }: Props): { values: ListItem[]; loading?: boolean } => { @@ -131,7 +135,8 @@ export const useValuesList = ({ }, }, }), - [debouncedQuery, from, to, JSON.stringify(filters), indexPatternTitle, sourceField] + [debouncedQuery, from, to, JSON.stringify(filters), indexPatternTitle, sourceField], + { name: `get${label.replace(/\s/g, '')}ValuesList` } ); useEffect(() => { diff --git a/x-pack/plugins/observability/public/index.ts b/x-pack/plugins/observability/public/index.ts index 22ad95b96f41f..2dd380c3b7683 100644 --- a/x-pack/plugins/observability/public/index.ts +++ b/x-pack/plugins/observability/public/index.ts @@ -83,5 +83,8 @@ export type { export { createObservabilityRuleTypeRegistryMock } from './rules/observability_rule_type_registry_mock'; export type { ExploratoryEmbeddableProps } from './components/shared/exploratory_view/embeddable/embeddable'; -export { InspectorContextProvider } from './context/inspector/inspector_context'; +export { + InspectorContextProvider, + AddInspectorRequest, +} from './context/inspector/inspector_context'; export { useInspectorContext } from './context/inspector/use_inspector_context'; diff --git a/x-pack/plugins/observability/server/index.ts b/x-pack/plugins/observability/server/index.ts index 53c3ecb23546c..57cfbebc3ccff 100644 --- a/x-pack/plugins/observability/server/index.ts +++ b/x-pack/plugins/observability/server/index.ts @@ -13,9 +13,12 @@ import { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/serve import { ObservabilityPlugin, ObservabilityPluginSetup } from './plugin'; import { createOrUpdateIndex, Mappings } from './utils/create_or_update_index'; import { ScopedAnnotationsClient } from './lib/annotations/bootstrap_annotations'; -import { unwrapEsResponse, WrappedElasticsearchClientError } from './utils/unwrap_es_response'; +import { + unwrapEsResponse, + WrappedElasticsearchClientError, +} from '../common/utils/unwrap_es_response'; export { rangeQuery, kqlQuery } from './utils/queries'; -export { getInspectResponse } from './utils/get_inspect_response'; +export { getInspectResponse } from '../common/utils/get_inspect_response'; export * from './types'; diff --git a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts index 39a594dcc86ca..98e8908cd60a5 100644 --- a/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts +++ b/x-pack/plugins/observability/server/lib/annotations/create_annotations_client.ts @@ -17,7 +17,7 @@ import { } from '../../../common/annotations'; import { createOrUpdateIndex } from '../../utils/create_or_update_index'; import { mappings } from './mappings'; -import { unwrapEsResponse } from '../../utils/unwrap_es_response'; +import { unwrapEsResponse } from '../../../common/utils/unwrap_es_response'; type CreateParams = t.TypeOf; type DeleteParams = t.TypeOf; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 3124324d90389..409436d734011 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -14,6 +14,7 @@ "requiredPlugins": [ "alerting", "embeddable", + "inspector", "features", "licensing", "triggersActionsUi", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index ed936fe164983..124de9a60110c 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -42,6 +42,7 @@ import { LazySyntheticsPolicyEditExtension, } from '../components/fleet_package'; import { LazySyntheticsCustomAssetsExtension } from '../components/fleet_package/lazy_synthetics_custom_assets_extension'; +import { Start as InspectorPluginStart } from '../../../../../src/plugins/inspector/public'; export interface ClientPluginsSetup { data: DataPublicPluginSetup; @@ -56,6 +57,7 @@ export interface ClientPluginsStart { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; fleet?: FleetStart; observability: ObservabilityPublicStart; + inspector: InspectorPluginStart; } export interface UptimePluginServices extends Partial { diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 73f228f621540..991657f258029 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -33,6 +33,7 @@ import { ActionMenu } from '../components/common/header/action_menu'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; import { UptimeIndexPatternContextProvider } from '../contexts/uptime_index_pattern_context'; +import { InspectorContextProvider } from '../../../observability/public'; export interface UptimeAppColors { danger: string; @@ -110,6 +111,7 @@ const Application = (props: UptimeAppProps) => { ...plugins, storage, data: startPlugins.data, + inspector: startPlugins.inspector, triggersActionsUi: startPlugins.triggersActionsUi, observability: startPlugins.observability, }} @@ -126,9 +128,11 @@ const Application = (props: UptimeAppProps) => { className={APP_WRAPPER_CLASS} application={core.application} > - - - + + + + + diff --git a/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx b/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx index 817bbf9bedcb1..c6eaeb78a7c1e 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_page_template.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import styled from 'styled-components'; import { EuiPageHeaderProps } from '@elastic/eui'; import { CERTIFICATES_ROUTE, OVERVIEW_ROUTE } from '../../common/constants'; @@ -15,6 +15,7 @@ import { useNoDataConfig } from './use_no_data_config'; import { EmptyStateLoading } from '../components/overview/empty_state/empty_state_loading'; import { EmptyStateError } from '../components/overview/empty_state/empty_state_error'; import { useHasData } from '../components/overview/empty_state/use_has_data'; +import { useInspectorContext } from '../../../observability/public'; interface Props { path: string; @@ -39,6 +40,11 @@ export const UptimePageTemplateComponent: React.FC = ({ path, pageHeader, const noDataConfig = useNoDataConfig(); const { loading, error, data } = useHasData(); + const { inspectorAdapters } = useInspectorContext(); + + useEffect(() => { + inspectorAdapters.requests.reset(); + }, [inspectorAdapters.requests]); if (error) { return ; diff --git a/x-pack/plugins/uptime/public/components/certificates/use_cert_search.ts b/x-pack/plugins/uptime/public/components/certificates/use_cert_search.ts index 22531faff2da1..c4379e550b47a 100644 --- a/x-pack/plugins/uptime/public/components/certificates/use_cert_search.ts +++ b/x-pack/plugins/uptime/public/components/certificates/use_cert_search.ts @@ -7,7 +7,7 @@ import { useSelector } from 'react-redux'; import { useContext } from 'react'; -import { useEsSearch, createEsParams } from '../../../../observability/public'; +import { createEsParams, useEsSearch } from '../../../../observability/public'; import { CertResult, GetCertsParams, Ping } from '../../../common/runtime_types'; @@ -48,13 +48,13 @@ export const useCertSearch = ({ body: searchBody, }); - const { data: result, loading } = useEsSearch(esParams, [ - settings.settings?.heartbeatIndices, - size, - pageIndex, - lastRefresh, - search, - ]); + const { data: result, loading } = useEsSearch( + esParams, + [settings.settings?.heartbeatIndices, size, pageIndex, lastRefresh, search], + { + name: 'getTLSCertificates', + } + ); return result ? { ...processCertsResult(result), loading } : { certs: [], total: 0, loading }; }; 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 c459fe46da975..21ef3428696e9 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 @@ -18,6 +18,7 @@ import { useGetUrlParams } from '../../../hooks'; import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; import { SETTINGS_ROUTE } from '../../../../common/constants'; import { stringifyUrlParams } from '../../../lib/helper/stringify_url_params'; +import { InspectorHeaderLink } from './inspector_header_link'; import { monitorStatusSelector } from '../../../state/selectors'; const ADD_DATA_LABEL = i18n.translate('xpack.uptime.addDataButtonLabel', { @@ -107,6 +108,7 @@ export function ActionMenuContent(): React.ReactElement { > {ADD_DATA_LABEL} + ); } diff --git a/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx new file mode 100644 index 0000000000000..8f787512aaf9d --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/header/inspector_header_link.tsx @@ -0,0 +1,39 @@ +/* + * 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 { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { enableInspectEsQueries, useInspectorContext } from '../../../../../observability/public'; +import { ClientPluginsStart } from '../../../apps/plugin'; + +export function InspectorHeaderLink() { + const { + services: { inspector, uiSettings }, + } = useKibana(); + + const { inspectorAdapters } = useInspectorContext(); + + const isInspectorEnabled = uiSettings?.get(enableInspectEsQueries); + + const inspect = () => { + inspector.open(inspectorAdapters); + }; + + if (!isInspectorEnabled) { + return null; + } + + return ( + + {i18n.translate('xpack.uptime.inspectButtonText', { + defaultMessage: 'Inspect', + })} + + ); +} diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.test.tsx index d066bf416e083..29c4a852e208b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout.test.tsx @@ -33,6 +33,7 @@ describe('ML Flyout component', () => { spy1.mockReturnValue(false); const value = { + isDevMode: true, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, @@ -48,6 +49,7 @@ describe('ML Flyout component', () => { onClose={onClose} canCreateMLJob={true} /> + uptime/public/state/api/utils.ts ); @@ -57,6 +59,7 @@ describe('ML Flyout component', () => { it('able to create job if valid license is available', async () => { const value = { + isDevMode: true, basePath: '', dateRangeStart: DATE_RANGE_START, dateRangeEnd: DATE_RANGE_END, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx index 8b23d867572f3..441ede99fd8b4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.test.tsx @@ -87,7 +87,8 @@ describe('useStepWaterfallMetrics', () => { }, index: 'heartbeat-*', }, - ['heartbeat-*', '44D-444FFF-444-FFF-3333', true] + ['heartbeat-*', '44D-444FFF-444-FFF-3333', true], + { name: 'getWaterfallStepMetrics' } ); expect(result.current).toEqual({ loading: false, diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts index cf60f6d7d5567..ad2762826c91f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_detail/use_step_waterfall_metrics.ts @@ -57,7 +57,10 @@ export const useStepWaterfallMetrics = ({ checkGroup, hasNavigationRequest, step }, }) : {}, - [heartbeatIndices, checkGroup, hasNavigationRequest] + [heartbeatIndices, checkGroup, hasNavigationRequest], + { + name: 'getWaterfallStepMetrics', + } ); if (!hasNavigationRequest) { diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx index 3980b4bf9d3da..835cbb8060142 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx @@ -8,9 +8,10 @@ import React, { useCallback, useState } from 'react'; import { EuiFilterGroup } from '@elastic/eui'; import styled from 'styled-components'; +import { capitalize } from 'lodash'; import { useFilterUpdate } from '../../../hooks/use_filter_update'; import { useSelectedFilters } from '../../../hooks/use_selected_filters'; -import { FieldValueSuggestions } from '../../../../../observability/public'; +import { FieldValueSuggestions, useInspectorContext } from '../../../../../observability/public'; import { SelectedFilters } from './selected_filters'; import { useIndexPattern } from '../../../contexts/uptime_index_pattern_context'; import { useGetUrlParams } from '../../../hooks'; @@ -34,6 +35,8 @@ export const FilterGroup = () => { const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); + const { inspectorAdapters } = useInspectorContext(); + const { filtersList } = useSelectedFilters(); const indexPattern = useIndexPattern(); @@ -67,6 +70,10 @@ export const FilterGroup = () => { filters={[]} cardinalityField="monitor.id" time={{ from: dateRangeStart, to: dateRangeEnd }} + inspector={{ + adapter: inspectorAdapters.requests, + title: 'get' + capitalize(label) + 'FilterValues', + }} /> ))} diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/use_monitor_histogram.ts b/x-pack/plugins/uptime/public/components/overview/monitor_list/use_monitor_histogram.ts index e1ef3d9efee89..a3985fe5ccca5 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/use_monitor_histogram.ts +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/use_monitor_histogram.ts @@ -37,10 +37,13 @@ export const useMonitorHistogram = ({ items }: { items: MonitorSummary[] }) => { monitorIds ); - const { data, loading } = useEsSearch(queryParams, [ - JSON.stringify(monitorIds), - lastRefresh, - ]); + const { data, loading } = useEsSearch( + queryParams, + [JSON.stringify(monitorIds), lastRefresh], + { + name: 'getMonitorDownHistory', + } + ); const histogramBuckets = data?.aggregations?.histogram.buckets ?? []; const simplified = histogramBuckets.map((histogramBucket) => { diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 9f7310b43e556..54f2110c88bc4 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -39,6 +39,8 @@ import { StepDetailPageRightSideItem, } from './pages/synthetics/step_detail_page'; import { UptimePageTemplateComponent } from './apps/uptime_page_template'; +import { apiService } from './state/api/utils'; +import { useInspectorContext } from '../../observability/public'; interface RouteProps { path: string; @@ -178,6 +180,10 @@ const RouteInit: React.FC> = }; export const PageRouter: FC = () => { + const { addInspectorRequest } = useInspectorContext(); + + apiService.addInspectorRequest = addInspectorRequest; + return ( {Routes.map( diff --git a/x-pack/plugins/uptime/public/state/api/snapshot.test.ts b/x-pack/plugins/uptime/public/state/api/snapshot.test.ts index 6c10bd0fa3cb7..38be97d74844f 100644 --- a/x-pack/plugins/uptime/public/state/api/snapshot.test.ts +++ b/x-pack/plugins/uptime/public/state/api/snapshot.test.ts @@ -18,6 +18,7 @@ describe('snapshot API', () => { get: jest.fn(), fetch: jest.fn(), } as any; + apiService.addInspectorRequest = jest.fn(); fetchMock = jest.spyOn(apiService.http, 'fetch'); mockResponse = { up: 3, down: 12, total: 15 }; }); diff --git a/x-pack/plugins/uptime/public/state/api/utils.ts b/x-pack/plugins/uptime/public/state/api/utils.ts index 91e017292d00f..d10064f1ff7a1 100644 --- a/x-pack/plugins/uptime/public/state/api/utils.ts +++ b/x-pack/plugins/uptime/public/state/api/utils.ts @@ -9,7 +9,7 @@ import { PathReporter } from 'io-ts/lib/PathReporter'; import { isRight } from 'fp-ts/lib/Either'; import { HttpFetchQuery, HttpSetup } from 'src/core/public'; import * as t from 'io-ts'; -import { startsWith } from 'lodash'; +import { FETCH_STATUS, AddInspectorRequest } from '../../../../observability/public'; function isObject(value: unknown) { const type = typeof value; @@ -43,6 +43,7 @@ export const formatErrors = (errors: t.Errors): string[] => { class ApiService { private static instance: ApiService; private _http!: HttpSetup; + private _addInspectorRequest!: AddInspectorRequest; public get http() { return this._http; @@ -52,6 +53,14 @@ class ApiService { this._http = httpSetup; } + public get addInspectorRequest() { + return this._addInspectorRequest; + } + + public set addInspectorRequest(addInspectorRequest: AddInspectorRequest) { + this._addInspectorRequest = addInspectorRequest; + } + private constructor() {} static getInstance(): ApiService { @@ -63,15 +72,14 @@ class ApiService { } public async get(apiUrl: string, params?: HttpFetchQuery, decodeType?: any, asResponse = false) { - const debugEnabled = - sessionStorage.getItem('uptime_debug') === 'true' && startsWith(apiUrl, '/api/uptime'); - const response = await this._http!.fetch({ path: apiUrl, - query: { ...params, ...(debugEnabled ? { _inspect: true } : {}) }, + query: params, asResponse, }); + this.addInspectorRequest?.({ data: response, status: FETCH_STATUS.SUCCESS, loading: false }); + if (decodeType) { const decoded = decodeType.decode(response); if (isRight(decoded)) { diff --git a/x-pack/plugins/uptime/server/lib/lib.ts b/x-pack/plugins/uptime/server/lib/lib.ts index 9b8ea6b98c8be..eb2ad9ce21b9e 100644 --- a/x-pack/plugins/uptime/server/lib/lib.ts +++ b/x-pack/plugins/uptime/server/lib/lib.ts @@ -18,6 +18,9 @@ import { UMLicenseCheck } from './domains'; import { UptimeRequests } from './requests'; import { savedObjectsAdapter } from './saved_objects'; import { ESSearchResponse } from '../../../../../src/core/types/elasticsearch'; +import { RequestStatus } from '../../../../../src/plugins/inspector'; +import { getInspectResponse } from '../../../observability/server'; +import { InspectResponse } from '../../../observability/typings/common'; export interface UMDomainLibs { requests: UptimeRequests; @@ -45,6 +48,8 @@ export interface CountResponse { export type UptimeESClient = ReturnType; +export const inspectableEsQueriesMap = new WeakMap(); + export function createUptimeESClient({ esClient, request, @@ -59,7 +64,8 @@ export function createUptimeESClient({ return { baseESClient: esClient, async search( - params: TParams + params: TParams, + operationName?: string ): Promise<{ body: ESSearchResponse }> { let res: any; let esError: any; @@ -70,11 +76,33 @@ export function createUptimeESClient({ const esParams = { index: dynamicSettings!.heartbeatIndices, ...params }; const startTime = process.hrtime(); + const startTimeNow = Date.now(); + + let esRequestStatus: RequestStatus = RequestStatus.PENDING; + try { res = await esClient.search(esParams); + esRequestStatus = RequestStatus.OK; } catch (e) { esError = e; + esRequestStatus = RequestStatus.ERROR; } + + const inspectableEsQueries = inspectableEsQueriesMap.get(request!); + if (inspectableEsQueries) { + inspectableEsQueries.push( + getInspectResponse({ + esError, + esRequestParams: esParams, + esRequestStatus, + esResponse: res.body, + kibanaRequest: request!, + operationName: operationName ?? '', + startTime: startTimeNow, + }) + ); + } + if (_inspect && request) { debugESCall({ startTime, request, esError, operationName: 'search', params: esParams }); } diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index 107a0f29e55fa..600a335effe2c 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -87,7 +87,7 @@ export const getPingHistogram: UMElasticsearchQueryFn { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts index aef01f29f4d57..ee4e3eb96eb5a 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_snapshot_counts.ts @@ -43,9 +43,12 @@ export const getSnapshotCount: UMElasticsearchQueryFn => { - const { body: res } = await context.search({ - body: statusCountBody(await context.dateAndCustomFilters(), context), - }); + const { body: res } = await context.search( + { + body: statusCountBody(await context.dateAndCustomFilters(), context), + }, + 'geSnapshotCount' + ); return ( (res.aggregations?.counts?.value as Snapshot) ?? { diff --git a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts index 179e9e809e59b..d0d8e61d02181 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/find_potential_matches.ts @@ -40,7 +40,7 @@ const query = async (queryContext: QueryContext, searchAfter: any, size: number) body, }; - const response = await queryContext.search(params); + const response = await queryContext.search(params, 'getMonitorList-potentialMatches'); return response; }; diff --git a/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts b/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts index 0bc503093f131..84c170363b1ee 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/query_context.ts @@ -43,8 +43,8 @@ export class QueryContext { this.query = query; } - async search(params: TParams) { - return this.callES.search(params); + async search(params: TParams, operationName?: string) { + return this.callES.search(params, operationName); } async count(params: any): Promise { diff --git a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts index 3ba2a90d07635..f8357e8066573 100644 --- a/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts +++ b/x-pack/plugins/uptime/server/lib/requests/search/refine_potential_matches.ts @@ -165,5 +165,5 @@ export const query = async ( }, }; - return await queryContext.search(params); + return await queryContext.search(params, 'getMonitorList-refinePotentialMatches'); }; diff --git a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts index 29a7a06f1530a..66f6d597344b6 100644 --- a/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts +++ b/x-pack/plugins/uptime/server/rest_api/index_state/get_index_status.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import { UMServerLibs } from '../../lib/lib'; import { UMRestApiRouteFactory } from '../types'; import { API_URLS } from '../../../common/constants'; @@ -13,11 +12,7 @@ import { API_URLS } from '../../../common/constants'; export const createGetIndexStatusRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', path: API_URLS.INDEX_STATUS, - validate: { - query: schema.object({ - _inspect: schema.maybe(schema.boolean()), - }), - }, + validate: {}, handler: async ({ uptimeEsClient }): Promise => { return await libs.requests.getIndexStatus({ uptimeEsClient }); }, diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts index 36bc5a80ef47a..df8463786449b 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_list.ts @@ -21,7 +21,6 @@ export const createMonitorListRoute: UMRestApiRouteFactory = (libs) => ({ statusFilter: schema.maybe(schema.string()), query: schema.maybe(schema.string()), pageSize: schema.number(), - _inspect: schema.maybe(schema.boolean()), }), }, options: { diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts index 77f265d0b81e8..de102f153d650 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_locations.ts @@ -18,7 +18,6 @@ export const createGetMonitorLocationsRoute: UMRestApiRouteFactory = (libs: UMSe monitorId: schema.string(), dateStart: schema.string(), dateEnd: schema.string(), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_status.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_status.ts index 94b50386ac216..ac5133fbb7b4e 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitor_status.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitor_status.ts @@ -18,7 +18,6 @@ export const createGetStatusBarRoute: UMRestApiRouteFactory = (libs: UMServerLib monitorId: schema.string(), dateStart: schema.string(), dateEnd: schema.string(), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts index 50712153a5fea..64e9ed504e7cd 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_details.ts @@ -18,7 +18,6 @@ export const createGetMonitorDetailsRoute: UMRestApiRouteFactory = (libs: UMServ monitorId: schema.string(), dateStart: schema.maybe(schema.string()), dateEnd: schema.maybe(schema.string()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, context, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts index e94198ee4e063..8dd9fbb7adb00 100644 --- a/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts +++ b/x-pack/plugins/uptime/server/rest_api/monitors/monitors_durations.ts @@ -19,7 +19,6 @@ export const createGetMonitorDurationRoute: UMRestApiRouteFactory = (libs: UMSer monitorId: schema.string(), dateStart: schema.string(), dateEnd: schema.string(), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts index db111390cfaf7..439975a0f5215 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts @@ -21,7 +21,6 @@ export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServe filters: schema.maybe(schema.string()), bucketSize: schema.maybe(schema.string()), query: schema.maybe(schema.string()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts index abb2c85f9ea0c..2be838c5e8658 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_pings.ts @@ -24,7 +24,6 @@ export const createGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) = size: schema.maybe(schema.number()), sort: schema.maybe(schema.string()), status: schema.maybe(schema.string()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request, response }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts index 3127c34590ef5..4b06a13d29f4e 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshot_blocks.ts @@ -22,9 +22,6 @@ export const createJourneyScreenshotBlocksRoute: UMRestApiRouteFactory = (libs: body: schema.object({ hashes: schema.arrayOf(schema.string()), }), - query: schema.object({ - _inspect: schema.maybe(schema.boolean()), - }), }, handler: async ({ request, response, uptimeEsClient }) => { const { hashes: blockIds } = request.body; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts index 5f0825279ecfa..3e71051816d30 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -26,10 +26,6 @@ export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServ params: schema.object({ checkGroup: schema.string(), stepIndex: schema.number(), - _inspect: schema.maybe(schema.boolean()), - }), - query: schema.object({ - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request, response }) => { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts index 284feda2c662b..7c3dcdfbe845c 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -22,7 +22,6 @@ export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => syntheticEventTypes: schema.maybe( schema.oneOf([schema.arrayOf(schema.string()), schema.string()]) ), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request, response }): Promise => { @@ -59,7 +58,6 @@ export const createJourneyFailedStepsRoute: UMRestApiRouteFactory = (libs: UMSer validate: { query: schema.object({ checkGroups: schema.arrayOf(schema.string()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request, response }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts b/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts index 67b106fdf6814..2fae13db7fa0d 100644 --- a/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts +++ b/x-pack/plugins/uptime/server/rest_api/snapshot/get_snapshot_count.ts @@ -19,7 +19,6 @@ export const createGetSnapshotCount: UMRestApiRouteFactory = (libs: UMServerLibs dateRangeEnd: schema.string(), filters: schema.maybe(schema.string()), query: schema.maybe(schema.string()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts index cb90de50e2510..5d1407a8679c8 100644 --- a/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts +++ b/x-pack/plugins/uptime/server/rest_api/synthetics/last_successful_step.ts @@ -22,7 +22,6 @@ export const createLastSuccessfulStepRoute: UMRestApiRouteFactory = (libs: UMSer monitorId: schema.string(), stepIndex: schema.number(), timestamp: schema.string(), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ uptimeEsClient, request, response }) => { diff --git a/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts index 088cf494efbf7..ec7de05dd2cf1 100644 --- a/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts +++ b/x-pack/plugins/uptime/server/rest_api/telemetry/log_page_view.ts @@ -22,7 +22,6 @@ export const createLogPageViewRoute: UMRestApiRouteFactory = () => ({ autoRefreshEnabled: schema.boolean(), autorefreshInterval: schema.number(), refreshTelemetryHistory: schema.maybe(schema.boolean()), - _inspect: schema.maybe(schema.boolean()), }), }, handler: async ({ savedObjectsClient, uptimeEsClient, request }): Promise => { diff --git a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts index 24e501a1bddb8..ddde993cc9c70 100644 --- a/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts +++ b/x-pack/plugins/uptime/server/rest_api/uptime_route_wrapper.ts @@ -6,10 +6,11 @@ */ import { UMKibanaRouteWrapper } from './types'; -import { createUptimeESClient } from '../lib/lib'; +import { createUptimeESClient, inspectableEsQueriesMap } from '../lib/lib'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { KibanaResponse } from '../../../../../src/core/server/http/router'; +import { enableInspectEsQueries } from '../../../observability/common'; export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute) => ({ ...uptimeRoute, @@ -20,11 +21,18 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute) => ({ const { client: esClient } = context.core.elasticsearch; const { client: savedObjectsClient } = context.core.savedObjects; + const isInspectorEnabled = await context.core.uiSettings.client.get( + enableInspectEsQueries + ); + const uptimeEsClient = createUptimeESClient({ request, savedObjectsClient, esClient: esClient.asCurrentUser, }); + if (isInspectorEnabled) { + inspectableEsQueriesMap.set(request, []); + } const res = await uptimeRoute.handler({ uptimeEsClient, @@ -41,6 +49,7 @@ export const uptimeRouteWrapper: UMKibanaRouteWrapper = (uptimeRoute) => ({ return response.ok({ body: { ...res, + ...(isInspectorEnabled ? { _inspect: inspectableEsQueriesMap.get(request) } : {}), }, }); }, From 7126149785c179a77883261280128aeba6b94656 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 12:52:34 -0400 Subject: [PATCH 03/54] [Security Solution][Endpoint] Change `trustedAppByPolicyEnabled` flag to `true` by default (#115264) (#115346) * set `trustedAppsByPolicyEnabled` flag to true by default * Adjust server tests Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../plugins/security_solution/common/experimental_features.ts | 2 +- .../server/fleet_integration/fleet_integration.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 148390324c13f..14b1bf8dc22dd 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -16,7 +16,7 @@ export const allowedExperimentalValues = Object.freeze({ ruleRegistryEnabled: false, tGridEnabled: true, tGridEventRenderedViewEnabled: true, - trustedAppsByPolicyEnabled: false, + trustedAppsByPolicyEnabled: true, excludePoliciesInFilterEnabled: false, uebaEnabled: false, disableIsolationUIPendingStatuses: false, diff --git a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts index d0bbb3b346ea8..2f31f54143f74 100644 --- a/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts +++ b/x-pack/plugins/security_solution/server/fleet_integration/fleet_integration.test.ts @@ -343,9 +343,10 @@ describe('ingest_integration tests ', () => { }); }); - it("doesn't remove policy from trusted app FF disabled", async () => { + it("doesn't remove policy from trusted app if feature flag is disabled", async () => { await invokeDeleteCallback({ ...allowedExperimentalValues, + trustedAppsByPolicyEnabled: false, // since it was changed to `true` by default }); expect(exceptionListClient.findExceptionListItem).toHaveBeenCalledTimes(0); From 875e2b757f623dc65cd9e659ba6992947850e91c Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 12:55:53 -0400 Subject: [PATCH 04/54] [Security Solution][Endpoint] Adjustments to the Policy Details layout for the Trusted Apps tab (#115093) (#115380) * Add link to view trusted apps list + spacing fixes * tests to cover changes to layout Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../policy_trusted_apps_layout.test.tsx | 2 +- .../layout/policy_trusted_apps_layout.tsx | 74 +++- .../list/policy_trusted_apps_list.test.tsx | 13 +- .../list/policy_trusted_apps_list.tsx | 385 +++++++++--------- 4 files changed, 273 insertions(+), 201 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx index d46775d38834b..e519d19d60fdc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx @@ -117,7 +117,7 @@ describe.skip('Policy trusted apps layout', () => { await waitForAction('assignedTrustedAppsListStateChanged'); - expect(component.getByTestId('policyDetailsTrustedAppsCount')).not.toBeNull(); + expect(component.getAllByTestId('policyTrustedAppsGrid-card')).toHaveLength(10); }); it('should hide assign button on empty state with unassigned policies when downgraded to a gold or below license', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx index 2421602f4e5af..a3f1ed215286a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx @@ -7,12 +7,16 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButton, EuiTitle, EuiPageHeader, EuiPageHeaderSection, EuiPageContent, + EuiText, + EuiSpacer, + EuiLink, } from '@elastic/eui'; import { PolicyTrustedAppsEmptyUnassigned, PolicyTrustedAppsEmptyUnexisting } from '../empty'; import { @@ -21,13 +25,18 @@ import { policyDetails, doesPolicyHaveTrustedApps, doesTrustedAppExistsLoading, + getPolicyTrustedAppsListPagination, } from '../../../store/policy_details/selectors'; import { usePolicyDetailsNavigateCallback, usePolicyDetailsSelector } from '../../policy_hooks'; import { PolicyTrustedAppsFlyout } from '../flyout'; import { PolicyTrustedAppsList } from '../list/policy_trusted_apps_list'; import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { APP_ID } from '../../../../../../../common/constants'; +import { getTrustedAppsListPath } from '../../../../../common/routing'; export const PolicyTrustedAppsLayout = React.memo(() => { + const { getAppUrl } = useAppUrl(); const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); const doesTrustedAppExists = usePolicyDetailsSelector(getDoesTrustedAppExists); const isDoesTrustedAppExistsLoading = usePolicyDetailsSelector(doesTrustedAppExistsLoading); @@ -35,6 +44,9 @@ export const PolicyTrustedAppsLayout = React.memo(() => { const navigateCallback = usePolicyDetailsNavigateCallback(); const hasAssignedTrustedApps = usePolicyDetailsSelector(doesPolicyHaveTrustedApps); const { isPlatinumPlus } = useEndpointPrivileges(); + const totalAssignedCount = usePolicyDetailsSelector( + getPolicyTrustedAppsListPagination + ).totalItemCount; const showListFlyout = location.show === 'list'; @@ -78,21 +90,57 @@ export const PolicyTrustedAppsLayout = React.memo(() => { [hasAssignedTrustedApps.loading, isDoesTrustedAppExistsLoading] ); + const aboutInfo = useMemo(() => { + const link = ( + + + + ); + + return ( + + ); + }, [getAppUrl, totalAssignedCount]); + return policyItem ? (
{!displaysEmptyStateIsLoading && !displaysEmptyState ? ( - - - -

- {i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.layout.title', { - defaultMessage: 'Assigned trusted applications', - })} -

-
-
- {isPlatinumPlus && assignTrustedAppButton} -
+ <> + + + +

+ {i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.title', + { + defaultMessage: 'Assigned trusted applications', + } + )} +

+
+ + + + +

{aboutInfo}

+
+
+ + {isPlatinumPlus && assignTrustedAppButton} +
+ + + ) : null} { /> ) ) : ( - + )} {isPlatinumPlus && showListFlyout ? : null} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index 316b70064d9db..0bf2195ba109f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -10,7 +10,7 @@ import { createAppRootMockRenderer, } from '../../../../../../common/mock/endpoint'; import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; -import { PolicyTrustedAppsList } from './policy_trusted_apps_list'; +import { PolicyTrustedAppsList, PolicyTrustedAppsListProps } from './policy_trusted_apps_list'; import React from 'react'; import { policyDetailsPageAllApiHttpMocks } from '../../../test_utils'; import { @@ -38,6 +38,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { let render: (waitForLoadedState?: boolean) => Promise>; let mockedApis: ReturnType; let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + let componentRenderProps: PolicyTrustedAppsListProps; const loadedUserEndpointPrivilegesState = ( endpointOverrides: Partial = {} @@ -93,6 +94,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { mockedApis = policyDetailsPageAllApiHttpMocks(appTestContext.coreStart.http); appTestContext.setExperimentalFlag({ trustedAppsByPolicyEnabled: true }); waitForAction = appTestContext.middlewareSpy.waitForAction; + componentRenderProps = {}; render = async (waitForLoadedState: boolean = true) => { appTestContext.history.push( @@ -106,7 +108,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { }) : Promise.resolve(); - renderResult = appTestContext.render(); + renderResult = appTestContext.render(); await trustedAppDataReceived; return renderResult; @@ -135,6 +137,13 @@ describe('when rendering the PolicyTrustedAppsList', () => { ); }); + it('should NOT show total number if `hideTotalShowingLabel` prop is true', async () => { + componentRenderProps.hideTotalShowingLabel = true; + await render(); + + expect(renderResult.queryByTestId('policyDetailsTrustedAppsCount')).toBeNull(); + }); + it('should show card grid', async () => { await render(); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index 8ab2f5fd465e0..f6afd9d502486 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -42,217 +42,232 @@ import { useEndpointPrivileges } from '../../../../../../common/components/user_ const DATA_TEST_SUBJ = 'policyTrustedAppsGrid'; -export const PolicyTrustedAppsList = memo(() => { - const getTestId = useTestIdGenerator(DATA_TEST_SUBJ); - const toasts = useToasts(); - const history = useHistory(); - const { getAppUrl } = useAppUrl(); - const { isPlatinumPlus } = useEndpointPrivileges(); - const policyId = usePolicyDetailsSelector(policyIdFromParams); - const hasTrustedApps = usePolicyDetailsSelector(doesPolicyHaveTrustedApps); - const isLoading = usePolicyDetailsSelector(isPolicyTrustedAppListLoading); - const isTrustedAppExistsCheckLoading = usePolicyDetailsSelector(doesTrustedAppExistsLoading); - const trustedAppItems = usePolicyDetailsSelector(getPolicyTrustedAppList); - const pagination = usePolicyDetailsSelector(getPolicyTrustedAppsListPagination); - const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); - const allPoliciesById = usePolicyDetailsSelector(getTrustedAppsAllPoliciesById); - const trustedAppsApiError = usePolicyDetailsSelector(getPolicyTrustedAppListError); +export interface PolicyTrustedAppsListProps { + hideTotalShowingLabel?: boolean; +} - const [isCardExpanded, setCardExpanded] = useState>({}); - const [trustedAppsForRemoval, setTrustedAppsForRemoval] = useState([]); - const [showRemovalModal, setShowRemovalModal] = useState(false); +export const PolicyTrustedAppsList = memo( + ({ hideTotalShowingLabel = false }) => { + const getTestId = useTestIdGenerator(DATA_TEST_SUBJ); + const toasts = useToasts(); + const history = useHistory(); + const { getAppUrl } = useAppUrl(); + const { isPlatinumPlus } = useEndpointPrivileges(); + const policyId = usePolicyDetailsSelector(policyIdFromParams); + const hasTrustedApps = usePolicyDetailsSelector(doesPolicyHaveTrustedApps); + const isLoading = usePolicyDetailsSelector(isPolicyTrustedAppListLoading); + const isTrustedAppExistsCheckLoading = usePolicyDetailsSelector(doesTrustedAppExistsLoading); + const trustedAppItems = usePolicyDetailsSelector(getPolicyTrustedAppList); + const pagination = usePolicyDetailsSelector(getPolicyTrustedAppsListPagination); + const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); + const allPoliciesById = usePolicyDetailsSelector(getTrustedAppsAllPoliciesById); + const trustedAppsApiError = usePolicyDetailsSelector(getPolicyTrustedAppListError); - const handlePageChange = useCallback( - ({ pageIndex, pageSize }) => { - history.push( - getPolicyDetailsArtifactsListPath(policyId, { - ...urlParams, - // If user changed page size, then reset page index back to the first page - page_index: pageSize !== pagination.pageSize ? 0 : pageIndex, - page_size: pageSize, - }) - ); - }, - [history, pagination.pageSize, policyId, urlParams] - ); + const [isCardExpanded, setCardExpanded] = useState>({}); + const [trustedAppsForRemoval, setTrustedAppsForRemoval] = useState([]); + const [showRemovalModal, setShowRemovalModal] = useState(false); - const handleExpandCollapse = useCallback( - ({ expanded, collapsed }) => { - const newCardExpandedSettings: Record = {}; + const handlePageChange = useCallback( + ({ pageIndex, pageSize }) => { + history.push( + getPolicyDetailsArtifactsListPath(policyId, { + ...urlParams, + // If user changed page size, then reset page index back to the first page + page_index: pageSize !== pagination.pageSize ? 0 : pageIndex, + page_size: pageSize, + }) + ); + }, + [history, pagination.pageSize, policyId, urlParams] + ); - for (const trustedApp of expanded) { - newCardExpandedSettings[trustedApp.id] = true; - } + const handleExpandCollapse = useCallback( + ({ expanded, collapsed }) => { + const newCardExpandedSettings: Record = {}; - for (const trustedApp of collapsed) { - newCardExpandedSettings[trustedApp.id] = false; - } + for (const trustedApp of expanded) { + newCardExpandedSettings[trustedApp.id] = true; + } - setCardExpanded(newCardExpandedSettings); - }, - [] - ); + for (const trustedApp of collapsed) { + newCardExpandedSettings[trustedApp.id] = false; + } - const totalItemsCountLabel = useMemo(() => { - return i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.totalCount', { - defaultMessage: - 'Showing {totalItemsCount, plural, one {# trusted application} other {# trusted applications}}', - values: { totalItemsCount: pagination.totalItemCount }, - }); - }, [pagination.totalItemCount]); + setCardExpanded(newCardExpandedSettings); + }, + [] + ); - const cardProps = useMemo, ArtifactCardGridCardComponentProps>>(() => { - const newCardProps = new Map(); + const totalItemsCountLabel = useMemo(() => { + return i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.totalCount', { + defaultMessage: + 'Showing {totalItemsCount, plural, one {# trusted application} other {# trusted applications}}', + values: { totalItemsCount: pagination.totalItemCount }, + }); + }, [pagination.totalItemCount]); - for (const trustedApp of trustedAppItems) { - const isGlobal = trustedApp.effectScope.type === 'global'; - const viewUrlPath = getTrustedAppsListPath({ id: trustedApp.id, show: 'edit' }); - const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = - trustedApp.effectScope.type === 'global' - ? undefined - : trustedApp.effectScope.policies.reduce< - Required['policies'] - >((byIdPolicies, trustedAppAssignedPolicyId) => { - if (!allPoliciesById[trustedAppAssignedPolicyId]) { - byIdPolicies[trustedAppAssignedPolicyId] = { children: trustedAppAssignedPolicyId }; - return byIdPolicies; - } + const cardProps = useMemo< + Map, ArtifactCardGridCardComponentProps> + >(() => { + const newCardProps = new Map(); - const policyDetailsPath = getPolicyDetailPath(trustedAppAssignedPolicyId); + for (const trustedApp of trustedAppItems) { + const isGlobal = trustedApp.effectScope.type === 'global'; + const viewUrlPath = getTrustedAppsListPath({ id: trustedApp.id, show: 'edit' }); + const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = + trustedApp.effectScope.type === 'global' + ? undefined + : trustedApp.effectScope.policies.reduce< + Required['policies'] + >((byIdPolicies, trustedAppAssignedPolicyId) => { + if (!allPoliciesById[trustedAppAssignedPolicyId]) { + byIdPolicies[trustedAppAssignedPolicyId] = { + children: trustedAppAssignedPolicyId, + }; + return byIdPolicies; + } - const thisPolicyMenuProps: ContextMenuItemNavByRouterProps = { - navigateAppId: APP_ID, - navigateOptions: { - path: policyDetailsPath, - }, - href: getAppUrl({ path: policyDetailsPath }), - children: allPoliciesById[trustedAppAssignedPolicyId].name, - }; + const policyDetailsPath = getPolicyDetailPath(trustedAppAssignedPolicyId); - byIdPolicies[trustedAppAssignedPolicyId] = thisPolicyMenuProps; + const thisPolicyMenuProps: ContextMenuItemNavByRouterProps = { + navigateAppId: APP_ID, + navigateOptions: { + path: policyDetailsPath, + }, + href: getAppUrl({ path: policyDetailsPath }), + children: allPoliciesById[trustedAppAssignedPolicyId].name, + }; - return byIdPolicies; - }, {}); + byIdPolicies[trustedAppAssignedPolicyId] = thisPolicyMenuProps; - const fullDetailsAction: ArtifactCardGridCardComponentProps['actions'] = [ - { - icon: 'controlsHorizontal', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.viewAction', - { defaultMessage: 'View full details' } - ), - href: getAppUrl({ appId: APP_ID, path: viewUrlPath }), - navigateAppId: APP_ID, - navigateOptions: { path: viewUrlPath }, - 'data-test-subj': getTestId('viewFullDetailsAction'), - }, - ]; - const thisTrustedAppCardProps: ArtifactCardGridCardComponentProps = { - expanded: Boolean(isCardExpanded[trustedApp.id]), - actions: isPlatinumPlus - ? [ - ...fullDetailsAction, - { - icon: 'trash', - children: i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeAction', - { defaultMessage: 'Remove from policy' } - ), - onClick: () => { - setTrustedAppsForRemoval([trustedApp]); - setShowRemovalModal(true); + return byIdPolicies; + }, {}); + + const fullDetailsAction: ArtifactCardGridCardComponentProps['actions'] = [ + { + icon: 'controlsHorizontal', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.viewAction', + { defaultMessage: 'View full details' } + ), + href: getAppUrl({ appId: APP_ID, path: viewUrlPath }), + navigateAppId: APP_ID, + navigateOptions: { path: viewUrlPath }, + 'data-test-subj': getTestId('viewFullDetailsAction'), + }, + ]; + const thisTrustedAppCardProps: ArtifactCardGridCardComponentProps = { + expanded: Boolean(isCardExpanded[trustedApp.id]), + actions: isPlatinumPlus + ? [ + ...fullDetailsAction, + { + icon: 'trash', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeAction', + { defaultMessage: 'Remove from policy' } + ), + onClick: () => { + setTrustedAppsForRemoval([trustedApp]); + setShowRemovalModal(true); + }, + disabled: isGlobal, + toolTipContent: isGlobal + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeActionNotAllowed', + { + defaultMessage: + 'Globally applied trusted applications cannot be removed from policy.', + } + ) + : undefined, + toolTipPosition: 'top', + 'data-test-subj': getTestId('removeAction'), }, - disabled: isGlobal, - toolTipContent: isGlobal - ? i18n.translate( - 'xpack.securitySolution.endpoint.policy.trustedApps.list.removeActionNotAllowed', - { - defaultMessage: - 'Globally applied trusted applications cannot be removed from policy.', - } - ) - : undefined, - toolTipPosition: 'top', - 'data-test-subj': getTestId('removeAction'), - }, - ] - : fullDetailsAction, + ] + : fullDetailsAction, - policies: assignedPoliciesMenuItems, - }; + policies: assignedPoliciesMenuItems, + }; - newCardProps.set(trustedApp, thisTrustedAppCardProps); - } + newCardProps.set(trustedApp, thisTrustedAppCardProps); + } - return newCardProps; - }, [allPoliciesById, getAppUrl, getTestId, isCardExpanded, trustedAppItems, isPlatinumPlus]); + return newCardProps; + }, [allPoliciesById, getAppUrl, getTestId, isCardExpanded, trustedAppItems, isPlatinumPlus]); - const provideCardProps = useCallback['cardComponentProps']>( - (item) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return cardProps.get(item as Immutable)!; - }, - [cardProps] - ); + const provideCardProps = useCallback['cardComponentProps']>( + (item) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cardProps.get(item as Immutable)!; + }, + [cardProps] + ); - const handleRemoveModalClose = useCallback(() => { - setShowRemovalModal(false); - }, []); + const handleRemoveModalClose = useCallback(() => { + setShowRemovalModal(false); + }, []); - // Anytime a new set of data (trusted apps) is retrieved, reset the card expand state - useEffect(() => { - setCardExpanded({}); - }, [trustedAppItems]); + // Anytime a new set of data (trusted apps) is retrieved, reset the card expand state + useEffect(() => { + setCardExpanded({}); + }, [trustedAppItems]); - // if an error occurred while loading the data, show toast - useEffect(() => { - if (trustedAppsApiError) { - toasts.addError(trustedAppsApiError as unknown as Error, { - title: i18n.translate('xpack.securitySolution.endpoint.policy.trustedApps.list.apiError', { - defaultMessage: 'Error while retrieving list of trusted applications', - }), - }); + // if an error occurred while loading the data, show toast + useEffect(() => { + if (trustedAppsApiError) { + toasts.addError(trustedAppsApiError as unknown as Error, { + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.list.apiError', + { + defaultMessage: 'Error while retrieving list of trusted applications', + } + ), + }); + } + }, [toasts, trustedAppsApiError]); + + if (hasTrustedApps.loading || isTrustedAppExistsCheckLoading) { + return ( + + + + ); } - }, [toasts, trustedAppsApiError]); - if (hasTrustedApps.loading || isTrustedAppExistsCheckLoading) { return ( - - - - ); - } - - return ( - <> - - {totalItemsCountLabel} - - - + <> + {!hideTotalShowingLabel && ( + + {totalItemsCountLabel} + + )} - + - {showRemovalModal && ( - - )} - - ); -}); + + {showRemovalModal && ( + + )} + + ); + } +); PolicyTrustedAppsList.displayName = 'PolicyTrustedAppsList'; From ea769990638d581807d7ea8536e7fbc4c20a6fc8 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 12:56:43 -0400 Subject: [PATCH 05/54] [Fleet] Add agent modal (#114830) (#115381) * remove toast * add modal * modal interactivity done * remove unused deps * onSaveNavigateTo cannot be a function any more * add util for constructing query string * move to policyId * plumb in queryParams * remove comments * move to strong tag * remove unused translations * fix unit tests * fix types * fix synthetics tests * add API comments * bonus: make package policy buttons uniform size * PR feedback: remove indent level Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Mark Hopkin --- .../components/index.ts | 1 + .../post_install_add_agent_modal.tsx | 59 +++++++ .../create_package_policy_page/index.tsx | 129 +++++++------- .../create_package_policy_page/types.ts | 8 +- .../utils/append_on_save_query_params.test.ts | 157 ++++++++++++++++++ .../utils/append_on_save_query_params.ts | 61 +++++++ .../create_package_policy_page/utils/index.ts | 8 + .../sections/epm/screens/detail/index.tsx | 13 +- .../components/package_policy_agents_cell.tsx | 2 +- .../detail/policies/package_policies.tsx | 9 +- .../public/types/intra_app_route_state.ts | 22 ++- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../apps/uptime/synthetics_integration.ts | 2 +- .../synthetics_integration_page.ts | 2 +- 15 files changed, 395 insertions(+), 84 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/post_install_add_agent_modal.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/append_on_save_query_params.test.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/append_on_save_query_params.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/index.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/index.ts index 14eca1406f623..c8b3a21f46ebd 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/index.ts @@ -8,3 +8,4 @@ export { CreatePackagePolicyPageLayout } from './layout'; export { PackagePolicyInputPanel } from './package_policy_input_panel'; export { PackagePolicyInputVarField } from './package_policy_input_var_field'; +export { PostInstallAddAgentModal } from './post_install_add_agent_modal'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/post_install_add_agent_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/post_install_add_agent_modal.tsx new file mode 100644 index 0000000000000..c91b6d348180d --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/post_install_add_agent_modal.tsx @@ -0,0 +1,59 @@ +/* + * 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 { EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import type { AgentPolicy, PackageInfo } from '../../../../types'; + +const toTitleCase = (str: string) => str.charAt(0).toUpperCase() + str.substr(1); + +export const PostInstallAddAgentModal: React.FunctionComponent<{ + onConfirm: () => void; + onCancel: () => void; + packageInfo: PackageInfo; + agentPolicy: AgentPolicy; +}> = ({ onConfirm, onCancel, packageInfo, agentPolicy }) => { + return ( + + } + onCancel={onCancel} + onConfirm={onConfirm} + cancelButtonText={ + + } + confirmButtonText={ + + } + buttonColor="primary" + data-test-subj="postInstallAddAgentModal" + > + Elastic Agent, + }} + /> + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx index ffc9cba90efea..f6ad41f69e99e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/index.tsx @@ -19,15 +19,19 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, - EuiLink, EuiErrorBoundary, } from '@elastic/eui'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import type { ApplicationStart } from 'kibana/public'; import { safeLoad } from 'js-yaml'; -import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; -import type { AgentPolicy, NewPackagePolicy, CreatePackagePolicyRouteState } from '../../../types'; +import type { + AgentPolicy, + NewPackagePolicy, + PackagePolicy, + CreatePackagePolicyRouteState, + OnSaveQueryParamKeys, +} from '../../../types'; import { useLink, useBreadcrumbs, @@ -45,10 +49,11 @@ import type { PackagePolicyEditExtensionComponentProps } from '../../../types'; import { PLUGIN_ID } from '../../../../../../common/constants'; import { pkgKeyFromPackageInfo } from '../../../services'; -import { CreatePackagePolicyPageLayout } from './components'; +import { CreatePackagePolicyPageLayout, PostInstallAddAgentModal } from './components'; import type { EditPackagePolicyFrom, PackagePolicyFormState } from './types'; import type { PackagePolicyValidationResults } from './services'; import { validatePackagePolicy, validationHasErrors } from './services'; +import { appendOnSaveQueryParamsToPath } from './utils'; import { StepSelectAgentPolicy } from './step_select_agent_policy'; import { StepConfigurePackagePolicy } from './step_configure_package'; import { StepDefinePackagePolicy } from './step_define_package_policy'; @@ -105,6 +110,9 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { // Agent policy state const [agentPolicy, setAgentPolicy] = useState(); + // only used to store the resulting package policy once saved + const [savedPackagePolicy, setSavedPackagePolicy] = useState(); + // Retrieve agent count const agentPolicyId = agentPolicy?.id; useEffect(() => { @@ -256,9 +264,9 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { const savePackagePolicy = useCallback(async () => { setFormState('LOADING'); const result = await sendCreatePackagePolicy(packagePolicy); - setFormState('SUBMITTED'); + setFormState(agentCount ? 'SUBMITTED' : 'SUBMITTED_NO_AGENTS'); return result; - }, [packagePolicy]); + }, [packagePolicy, agentCount]); const doOnSaveNavigation = useRef(true); // Detect if user left page @@ -268,6 +276,39 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { }; }, []); + const navigateAddAgent = (policy?: PackagePolicy) => + onSaveNavigate(policy, ['openEnrollmentFlyout']); + + const navigateAddAgentHelp = (policy?: PackagePolicy) => + onSaveNavigate(policy, ['showAddAgentHelp']); + + const onSaveNavigate = useCallback( + (policy?: PackagePolicy, paramsToApply: OnSaveQueryParamKeys[] = []) => { + if (!doOnSaveNavigation.current) { + return; + } + + if (routeState?.onSaveNavigateTo && policy) { + const [appId, options] = routeState.onSaveNavigateTo; + + if (options?.path) { + const pathWithQueryString = appendOnSaveQueryParamsToPath({ + path: options.path, + policy, + mappingOptions: routeState.onSaveQueryParams, + paramsToApply, + }); + handleNavigateTo([appId, { ...options, path: pathWithQueryString }]); + } else { + handleNavigateTo(routeState.onSaveNavigateTo); + } + } else { + history.push(getPath('policy_details', { policyId: agentPolicy!.id })); + } + }, + [agentPolicy, getPath, handleNavigateTo, history, routeState] + ); + const onSubmit = useCallback(async () => { if (formState === 'VALID' && hasErrors) { setFormState('INVALID'); @@ -279,27 +320,14 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { } const { error, data } = await savePackagePolicy(); if (!error) { - if (doOnSaveNavigation.current) { - if (routeState && routeState.onSaveNavigateTo) { - handleNavigateTo( - typeof routeState.onSaveNavigateTo === 'function' - ? routeState.onSaveNavigateTo(data!.item) - : routeState.onSaveNavigateTo - ); - } else { - history.push( - getPath('policy_details', { - policyId: agentPolicy!.id, - }) - ); - } - } - - const fromPolicyWithoutAgentsAssigned = from === 'policy' && agentPolicy && agentCount === 0; - - const fromPackageWithoutAgentsAssigned = packageInfo && agentPolicy && agentCount === 0; + setSavedPackagePolicy(data!.item); const hasAgentsAssigned = agentCount && agentPolicy; + if (!hasAgentsAssigned) { + setFormState('SUBMITTED_NO_AGENTS'); + return; + } + onSaveNavigate(data!.item); notifications.toasts.addSuccess({ title: i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationTitle', { @@ -308,40 +336,7 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { packagePolicyName: packagePolicy.name, }, }), - text: fromPolicyWithoutAgentsAssigned - ? i18n.translate( - 'xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage', - { - defaultMessage: `The policy has been updated. Add an agent to the '{agentPolicyName}' policy to deploy this policy.`, - values: { - agentPolicyName: agentPolicy!.name, - }, - } - ) - : fromPackageWithoutAgentsAssigned - ? toMountPoint( - // To render the link below we need to mount this JSX in the success toast - - {i18n.translate( - 'xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage', - { defaultMessage: 'add an agent' } - )} - - ), - }} - /> - ) - : hasAgentsAssigned + text: hasAgentsAssigned ? i18n.translate('xpack.fleet.createPackagePolicy.addedNotificationMessage', { defaultMessage: `Fleet will deploy updates to all agents that use the '{agentPolicyName}' policy.`, values: { @@ -362,16 +357,10 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { hasErrors, agentCount, savePackagePolicy, - from, + onSaveNavigate, agentPolicy, - packageInfo, notifications.toasts, packagePolicy.name, - getHref, - routeState, - handleNavigateTo, - history, - getPath, ]); const integrationInfo = useMemo( @@ -508,6 +497,14 @@ export const CreatePackagePolicyPage: React.FunctionComponent = () => { onCancel={() => setFormState('VALID')} /> )} + {formState === 'SUBMITTED_NO_AGENTS' && agentPolicy && packageInfo && ( + navigateAddAgent(savedPackagePolicy)} + onCancel={() => navigateAddAgentHelp(savedPackagePolicy)} + /> + )} {packageInfo && ( { + it('should do nothing if no paramsToApply provided', () => { + expect( + appendOnSaveQueryParamsToPath({ path: '/hello', policy: mockPolicy, paramsToApply: [] }) + ).toEqual('/hello'); + }); + it('should do nothing if all params set to false', () => { + const options = { + path: '/hello', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: false, + openEnrollmentFlyout: false, + }, + paramsToApply: ['showAddAgentHelp', 'openEnrollmentFlyout'] as OnSaveQueryParamKeys[], + }; + expect(appendOnSaveQueryParamsToPath(options)).toEqual('/hello'); + }); + + it('should append query params if set to true', () => { + const options = { + path: '/hello', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: true, + openEnrollmentFlyout: true, + }, + paramsToApply: ['showAddAgentHelp', 'openEnrollmentFlyout'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ showAddAgentHelp: 'true', openEnrollmentFlyout: 'true' }); + }); + it('should append query params if set to true (existing query string)', () => { + const options = { + path: '/hello?world=1', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: true, + openEnrollmentFlyout: true, + }, + paramsToApply: ['showAddAgentHelp', 'openEnrollmentFlyout'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ showAddAgentHelp: 'true', openEnrollmentFlyout: 'true', world: '1' }); + }); + + it('should append renamed param', () => { + const options = { + path: '/hello', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: { renameKey: 'renamedKey' }, + }, + paramsToApply: ['showAddAgentHelp'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ renamedKey: 'true' }); + }); + + it('should append renamed param (existing param)', () => { + const options = { + path: '/hello?world=1', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: { renameKey: 'renamedKey' }, + }, + paramsToApply: ['showAddAgentHelp'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ renamedKey: 'true', world: '1' }); + }); + + it('should append renamed param and policyId', () => { + const options = { + path: '/hello', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: { renameKey: 'renamedKey', policyIdAsValue: true }, + }, + paramsToApply: ['showAddAgentHelp'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ renamedKey: mockPolicy.policy_id }); + }); + + it('should append renamed param and policyId (existing param)', () => { + const options = { + path: '/hello?world=1', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: { renameKey: 'renamedKey', policyIdAsValue: true }, + }, + paramsToApply: ['showAddAgentHelp'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ renamedKey: mockPolicy.policy_id, world: '1' }); + }); + + it('should append renamed params and policyIds (existing param)', () => { + const options = { + path: '/hello?world=1', + policy: mockPolicy, + mappingOptions: { + showAddAgentHelp: { renameKey: 'renamedKey', policyIdAsValue: true }, + openEnrollmentFlyout: { renameKey: 'renamedKey2', policyIdAsValue: true }, + }, + paramsToApply: ['showAddAgentHelp', 'openEnrollmentFlyout'] as OnSaveQueryParamKeys[], + }; + + const hrefOut = appendOnSaveQueryParamsToPath(options); + const [basePath, qs] = parseHref(hrefOut); + expect(basePath).toEqual('/hello'); + expect(qs).toEqual({ + renamedKey: mockPolicy.policy_id, + renamedKey2: mockPolicy.policy_id, + world: '1', + }); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/append_on_save_query_params.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/append_on_save_query_params.ts new file mode 100644 index 0000000000000..4b7e3c61806ce --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/append_on_save_query_params.ts @@ -0,0 +1,61 @@ +/* + * 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 { parse, stringify } from 'query-string'; + +import type { + CreatePackagePolicyRouteState, + OnSaveQueryParamOpts, + PackagePolicy, + OnSaveQueryParamKeys, +} from '../../../../types'; + +export function appendOnSaveQueryParamsToPath({ + path, + policy, + paramsToApply, + mappingOptions = {}, +}: { + path: string; + policy: PackagePolicy; + paramsToApply: OnSaveQueryParamKeys[]; + mappingOptions?: CreatePackagePolicyRouteState['onSaveQueryParams']; +}) { + const [basePath, queryStringIn] = path.split('?'); + const queryParams = parse(queryStringIn); + + paramsToApply.forEach((paramName) => { + const paramOptions = mappingOptions[paramName]; + if (paramOptions) { + const [paramKey, paramValue] = createQueryParam(paramName, paramOptions, policy.policy_id); + if (paramKey && paramValue) { + queryParams[paramKey] = paramValue; + } + } + }); + + const queryString = stringify(queryParams); + + return basePath + (queryString ? `?${queryString}` : ''); +} + +function createQueryParam( + name: string, + opts: OnSaveQueryParamOpts, + policyId: string +): [string?, string?] { + if (!opts) { + return []; + } + if (typeof opts === 'boolean' && opts) { + return [name, 'true']; + } + + const paramKey = opts.renameKey ? opts.renameKey : name; + const paramValue = opts.policyIdAsValue ? policyId : 'true'; + + return [paramKey, paramValue]; +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/index.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/index.ts new file mode 100644 index 0000000000000..15de46e1dc588 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { appendOnSaveQueryParamsToPath } from './append_on_save_query_params'; 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 ade290aab4e5e..881fc566c932d 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 @@ -250,7 +250,7 @@ export function Detail() { let redirectToPath: CreatePackagePolicyRouteState['onSaveNavigateTo'] & CreatePackagePolicyRouteState['onCancelNavigateTo']; - + let onSaveQueryParams: CreatePackagePolicyRouteState['onSaveQueryParams']; if (agentPolicyIdFromContext) { redirectToPath = [ PLUGIN_ID, @@ -260,6 +260,11 @@ export function Detail() { })[1], }, ]; + + onSaveQueryParams = { + showAddAgentHelp: true, + openEnrollmentFlyout: true, + }; } else { redirectToPath = [ INTEGRATIONS_PLUGIN_ID, @@ -269,10 +274,16 @@ export function Detail() { })[1], }, ]; + + onSaveQueryParams = { + showAddAgentHelp: { renameKey: 'showAddAgentHelpForPolicyId', policyIdAsValue: true }, + openEnrollmentFlyout: { renameKey: 'addAgentToPolicyId', policyIdAsValue: true }, + }; } const redirectBackRouteState: CreatePackagePolicyRouteState = { onSaveNavigateTo: redirectToPath, + onSaveQueryParams, onCancelNavigateTo: [ INTEGRATIONS_PLUGIN_ID, { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/components/package_policy_agents_cell.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/components/package_policy_agents_cell.tsx index e70d10e735571..0ecab3290051e 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/components/package_policy_agents_cell.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/policies/components/package_policy_agents_cell.tsx @@ -13,7 +13,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { LinkedAgentCount, AddAgentHelpPopover } from '../../../../../../components'; const AddAgentButton = ({ onAddAgent }: { onAddAgent: () => void }) => ( - + agentPolicy.id === showAddAgentHelpForPolicyId + )?.packagePolicy?.id; // Handle the "add agent" link displayed in post-installation toast notifications in the case // where a user is clicking the link while on the package policies listing page useEffect(() => { @@ -292,13 +295,13 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps name: i18n.translate('xpack.fleet.epm.packageDetails.integrationList.agentCount', { defaultMessage: 'Agents', }), - render({ agentPolicy }: InMemoryPackagePolicyAndAgentPolicy) { + render({ agentPolicy, packagePolicy }: InMemoryPackagePolicyAndAgentPolicy) { return ( setFlyoutOpenForPolicyId(agentPolicy.id)} - hasHelpPopover={showAddAgentHelpForPolicyId === agentPolicy.id} + hasHelpPopover={showAddAgentHelpForPackagePolicyId === packagePolicy.id} /> ); }, @@ -326,7 +329,7 @@ export const PackagePoliciesPage = ({ name, version }: PackagePoliciesPanelProps }, }, ], - [getHref, showAddAgentHelpForPolicyId, viewDataStep] + [getHref, showAddAgentHelpForPackagePolicyId, viewDataStep] ); const noItemsMessage = useMemo(() => { diff --git a/x-pack/plugins/fleet/public/types/intra_app_route_state.ts b/x-pack/plugins/fleet/public/types/intra_app_route_state.ts index 36fd32c2a6584..0ea40e6fe5695 100644 --- a/x-pack/plugins/fleet/public/types/intra_app_route_state.ts +++ b/x-pack/plugins/fleet/public/types/intra_app_route_state.ts @@ -7,20 +7,34 @@ import type { ApplicationStart } from 'kibana/public'; -import type { PackagePolicy } from './'; +/** + * Supported query parameters for CreatePackagePolicyRouteState + */ +export type OnSaveQueryParamKeys = 'showAddAgentHelp' | 'openEnrollmentFlyout'; +/** + * Query string parameter options for CreatePackagePolicyRouteState + */ +export type OnSaveQueryParamOpts = + | { + renameKey?: string; // override param name + policyIdAsValue?: boolean; // use policyId as param value instead of true + } + | boolean; /** * Supported routing state for the create package policy page routes */ export interface CreatePackagePolicyRouteState { /** On a successful save of the package policy, use navigate to the given app */ - onSaveNavigateTo?: - | Parameters - | ((newPackagePolicy: PackagePolicy) => Parameters); + onSaveNavigateTo?: Parameters; /** On cancel, navigate to the given app */ onCancelNavigateTo?: Parameters; /** Url to be used on cancel links */ onCancelUrl?: string; + /** supported query params for onSaveNavigateTo path */ + onSaveQueryParams?: { + [key in OnSaveQueryParamKeys]?: OnSaveQueryParamOpts; + }; } /** diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eed8d57ae3478..8da57b473c760 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10990,13 +10990,10 @@ "xpack.fleet.createPackagePolicy.cancelButton": "キャンセル", "xpack.fleet.createPackagePolicy.cancelLinkText": "キャンセル", "xpack.fleet.createPackagePolicy.errorOnSaveText": "統合ポリシーにはエラーがあります。保存前に修正してください。", - "xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage": "エージェントを追加", - "xpack.fleet.createPackagePolicy.integrationsContextaddAgentNextNotificationMessage": "次に、{link}して、データの取り込みを開始します。", "xpack.fleet.createPackagePolicy.pageDescriptionfromPackage": "次の手順に従い、この統合をエージェントポリシーに追加します。", "xpack.fleet.createPackagePolicy.pageDescriptionfromPolicy": "選択したエージェントポリシーの統合を構成します。", "xpack.fleet.createPackagePolicy.pageTitle": "統合の追加", "xpack.fleet.createPackagePolicy.pageTitleWithPackageName": "{packageName}統合の追加", - "xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage": "ポリシーが更新されました。エージェントを'{agentPolicyName}'ポリシーに追加して、このポリシーをデプロイします。", "xpack.fleet.createPackagePolicy.saveButton": "統合の保存", "xpack.fleet.createPackagePolicy.stepConfigure.advancedOptionsToggleLinkText": "高度なオプション", "xpack.fleet.createPackagePolicy.stepConfigure.hideStreamsAriaLabel": "{type}入力を非表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6080646adbb9c..9dadf6ca4ef5d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11100,13 +11100,10 @@ "xpack.fleet.createPackagePolicy.cancelButton": "取消", "xpack.fleet.createPackagePolicy.cancelLinkText": "取消", "xpack.fleet.createPackagePolicy.errorOnSaveText": "您的集成策略有错误。请在保存前修复这些错误。", - "xpack.fleet.createPackagePolicy.integrationsContextAddAgentLinkMessage": "添加代理", - "xpack.fleet.createPackagePolicy.integrationsContextaddAgentNextNotificationMessage": "接着,{link}以开始采集数据。", "xpack.fleet.createPackagePolicy.pageDescriptionfromPackage": "按照以下说明将此集成添加到代理策略。", "xpack.fleet.createPackagePolicy.pageDescriptionfromPolicy": "为选定代理策略配置集成。", "xpack.fleet.createPackagePolicy.pageTitle": "添加集成", "xpack.fleet.createPackagePolicy.pageTitleWithPackageName": "添加 {packageName} 集成", - "xpack.fleet.createPackagePolicy.policyContextAddAgentNextNotificationMessage": "策略已更新。将代理添加到“{agentPolicyName}”代理,以部署此策略。", "xpack.fleet.createPackagePolicy.saveButton": "保存集成", "xpack.fleet.createPackagePolicy.stepConfigure.advancedOptionsToggleLinkText": "高级选项", "xpack.fleet.createPackagePolicy.stepConfigure.errorCountText": "{count, plural, other {# 个错误}}", diff --git a/x-pack/test/functional/apps/uptime/synthetics_integration.ts b/x-pack/test/functional/apps/uptime/synthetics_integration.ts index 1fe13227d2546..b805b044e1894 100644 --- a/x-pack/test/functional/apps/uptime/synthetics_integration.ts +++ b/x-pack/test/functional/apps/uptime/synthetics_integration.ts @@ -166,7 +166,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const saveButton = await uptimePage.syntheticsIntegration.findSaveButton(); await saveButton.click(); - await testSubjects.missingOrFail('packagePolicyCreateSuccessToast'); + await testSubjects.missingOrFail('postInstallAddAgentModal'); }); }); diff --git a/x-pack/test/functional/page_objects/synthetics_integration_page.ts b/x-pack/test/functional/page_objects/synthetics_integration_page.ts index 5551ea2c3bcd0..80c4699f6c211 100644 --- a/x-pack/test/functional/page_objects/synthetics_integration_page.ts +++ b/x-pack/test/functional/page_objects/synthetics_integration_page.ts @@ -61,7 +61,7 @@ export function SyntheticsIntegrationPageProvider({ * Determines if the policy was created successfully by looking for the creation success toast */ async isPolicyCreatedSuccessfully() { - await testSubjects.existOrFail('packagePolicyCreateSuccessToast'); + await testSubjects.existOrFail('postInstallAddAgentModal'); }, /** From df2a9949d9cae6b8fb220551ec77e5dda0b0e79a Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 12:57:28 -0400 Subject: [PATCH 06/54] [Security Solution][Endpoint] Ensure that ArtifactEntryCard component, displays OS localized if artifact is defined for multiple OSs (#115222) (#115379) * Fix localizing `os` if the artifact is assigned to multiple OSs * Test case to validate that multiple OSs are i18n * updated trusted apps test snapshots Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../artifact_entry_card.test.tsx | 18 ++++++++++++++++++ .../components/criteria_conditions.tsx | 14 +++++++++----- .../components/artifact_entry_card/types.ts | 2 +- .../utils/map_to_artifact_info.ts | 7 +------ .../__snapshots__/index.test.tsx.snap | 18 +++++++++--------- 5 files changed, 38 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx index 50500a789fd4e..f6d394070daf1 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -12,6 +12,8 @@ import { act, fireEvent, getByTestId } from '@testing-library/react'; import { AnyArtifact } from './types'; import { isTrustedApp } from './utils'; import { getTrustedAppProviderMock, getExceptionProviderMock } from './test_utils'; +import { OS_LINUX, OS_MAC, OS_WINDOWS } from './components/translations'; +import { TrustedApp } from '../../../../common/endpoint/types'; describe.each([ ['trusted apps', getTrustedAppProviderMock], @@ -111,6 +113,22 @@ describe.each([ ); }); + it('should display multiple OSs in the criteria conditions', () => { + if (isTrustedApp(item)) { + // Trusted apps does not support multiple OS, so this is just so the test will pass + // for the trusted app run (the top level `describe()` uses a `.each()`) + item.os = [OS_LINUX, OS_MAC, OS_WINDOWS].join(', ') as TrustedApp['os']; + } else { + item.os_types = ['linux', 'macos', 'windows']; + } + + render(); + + expect(renderResult.getByTestId('testCard-criteriaConditions').textContent).toEqual( + ` OSIS ${OS_LINUX}, ${OS_MAC}, ${OS_WINDOWS}AND process.hash.*IS 1234234659af249ddf3e40864e9fb241AND process.executable.caselessIS /one/two/three` + ); + }); + it('should NOT show the action menu button if no actions were provided', async () => { render(); const menuButton = await renderResult.queryByTestId('testCard-header-actions-button'); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx index 260db313ced36..4557a7339d3d5 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { CommonProps, EuiExpression, EuiToken, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; @@ -28,6 +28,7 @@ import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; const OS_LABELS = Object.freeze({ linux: OS_LINUX, mac: OS_MAC, + macos: OS_MAC, windows: OS_WINDOWS, }); @@ -56,6 +57,12 @@ export const CriteriaConditions = memo( ({ os, entries, 'data-test-subj': dataTestSubj }) => { const getTestId = useTestIdGenerator(dataTestSubj); + const osLabel = useMemo(() => { + return os + .map((osValue) => OS_LABELS[osValue as keyof typeof OS_LABELS] ?? osValue) + .join(', '); + }, [os]); + const getNestedEntriesContent = useCallback( (type: string, nestedEntries: ArtifactInfoEntry[]) => { if (type === 'nested' && nestedEntries.length) { @@ -99,10 +106,7 @@ export const CriteriaConditions = memo(
- +
{entries.map(({ field, type, value, entries: nestedEntries = [] }) => { diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/types.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/types.ts index fe50a15190f11..0fd3269500f34 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/types.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/types.ts @@ -27,7 +27,7 @@ export interface ArtifactInfo 'name' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by' | 'description' | 'comments' > { effectScope: EffectScope; - os: string; + os: string[]; entries: ArtifactInfoEntries[]; } diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/utils/map_to_artifact_info.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/utils/map_to_artifact_info.ts index 5969cf9d043b4..60224b63f426f 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/utils/map_to_artifact_info.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/utils/map_to_artifact_info.ts @@ -26,16 +26,11 @@ export const mapToArtifactInfo = (_item: MaybeImmutable): ArtifactI description, comments: isTrustedApp(item) ? [] : item.comments, entries: entries as unknown as ArtifactInfo['entries'], - os: isTrustedApp(item) ? item.os : getOsFromExceptionItem(item), + os: isTrustedApp(item) ? [item.os] : item.os_types ?? [], effectScope: isTrustedApp(item) ? item.effectScope : getEffectScopeFromExceptionItem(item), }; }; -const getOsFromExceptionItem = (item: ExceptionListItemSchema): string => { - // FYI: Exceptions seem to allow for items to be assigned to more than one OS, unlike Event Filters and Trusted Apps - return item.os_types.join(', '); -}; - const getEffectScopeFromExceptionItem = (item: ExceptionListItemSchema): EffectScope => { return tagsToEffectScope(item.tags); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index e2b5ad43e40f2..cc7ae7fe1a658 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -1164,7 +1164,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` - macos + Mac @@ -2313,7 +2313,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` - macos + Mac @@ -3462,7 +3462,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` - macos + Mac @@ -5289,7 +5289,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time - macos + Mac @@ -6438,7 +6438,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time - macos + Mac @@ -7587,7 +7587,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time - macos + Mac @@ -9371,7 +9371,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not - macos + Mac @@ -10520,7 +10520,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not - macos + Mac @@ -11669,7 +11669,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not - macos + Mac From 9dfecb30b48286ce025290eb4857b0d5cccb54f5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 13:01:40 -0400 Subject: [PATCH 07/54] [Security Solution][Endpoint] Adjustments to the implementation of the new artifact card (#114834) (#115360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ui changes and add skeleton when loading policies * Fix tests * Changes in context menu for effect scope * Addjust card header and title * Adjust marging for operator in critera conditions * Fix and adds unit tests * fix multilang key * Fix ts error * Addressed pr comments * Fixes unit tests and changed prop name. Also set max-width to 50% for hover info * Don't render flexItem if no needed * Fixes unit test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: David Sánchez --- .../use_navigate_to_app_event_handler.ts | 7 +- .../artifact_entry_card.test.tsx | 4 +- .../artifact_entry_card.tsx | 5 +- .../components/card_header.tsx | 4 +- .../components/card_section_panel.tsx | 7 +- .../components/card_sub_header.tsx | 18 +- .../components/criteria_conditions.tsx | 10 +- .../components/date_field_value.tsx | 16 +- .../components/effect_scope.tsx | 60 +- .../components/text_value_display.tsx | 27 +- .../components/touched_by_users.tsx | 13 +- .../components/translations.ts | 8 + .../context_menu_item_nav_by_router.tsx | 81 +- .../context_menu_with_router_support.test.tsx | 19 + .../context_menu_with_router_support.tsx | 53 +- .../list/policy_trusted_apps_list.test.tsx | 4 +- .../pages/trusted_apps/store/selectors.ts | 5 + .../__snapshots__/index.test.tsx.snap | 876 +++++++++--------- .../components/trusted_apps_grid/index.tsx | 7 +- 19 files changed, 736 insertions(+), 488 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts index 05e1c2c4dca81..ed75e3bbbe926 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/endpoint/use_navigate_to_app_event_handler.ts @@ -37,7 +37,8 @@ export const useNavigateToAppEventHandler = ( options?: NavigateToAppHandlerOptions ): EventHandlerCallback => { const { services } = useKibana(); - const { path, state, onClick, deepLinkId } = options || {}; + const { path, state, onClick, deepLinkId, openInNewTab } = options || {}; + return useCallback( (ev) => { try { @@ -70,8 +71,8 @@ export const useNavigateToAppEventHandler = ( } ev.preventDefault(); - services.application.navigateToApp(appId, { deepLinkId, path, state }); + services.application.navigateToApp(appId, { deepLinkId, path, state, openInNewTab }); }, - [appId, deepLinkId, onClick, path, services.application, state] + [appId, deepLinkId, onClick, path, services.application, state, openInNewTab] ); }; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx index f6d394070daf1..36362463b5ea2 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -216,7 +216,9 @@ describe.each([ renderResult.getByTestId('testCard-subHeader-effectScope-popupMenu-popoverPanel') ).not.toBeNull(); - expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual('Policy one'); + expect(renderResult.getByTestId('policyMenuItem').textContent).toEqual( + 'Policy oneView details' + ); }); it('should display policy ID if no policy menu item found in `policies` prop', async () => { diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx index bee9a63c9cf69..e974557c36e0a 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.tsx @@ -33,6 +33,7 @@ export interface CommonArtifactEntryCardProps extends CommonProps { * `Record`. */ policies?: MenuItemPropsByPolicyId; + loadingPoliciesList?: boolean; } export interface ArtifactEntryCardProps extends CommonArtifactEntryCardProps { @@ -50,6 +51,7 @@ export const ArtifactEntryCard = memo( ({ item, policies, + loadingPoliciesList = false, actions, hideDescription = false, hideComments = false, @@ -74,10 +76,11 @@ export const ArtifactEntryCard = memo( createdBy={artifact.created_by} updatedBy={artifact.updated_by} policies={policyNavLinks} + loadingPoliciesList={loadingPoliciesList} data-test-subj={getTestId('subHeader')} /> - + {!hideDescription ? ( diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_header.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_header.tsx index 6964f5b339312..c1f3f257b278a 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_header.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_header.tsx @@ -24,9 +24,9 @@ export const CardHeader = memo( const getTestId = useTestIdGenerator(dataTestSubj); return ( - + - +

{name}

diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_section_panel.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_section_panel.tsx index 1d694ab1771d3..75e55a72f7f07 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_section_panel.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_section_panel.tsx @@ -6,6 +6,7 @@ */ import React, { memo } from 'react'; +import styled from 'styled-components'; import { EuiPanel, EuiPanelProps } from '@elastic/eui'; export type CardSectionPanelProps = Exclude< @@ -13,7 +14,11 @@ export type CardSectionPanelProps = Exclude< 'hasBorder' | 'hasShadow' | 'paddingSize' >; +const StyledEuiPanel = styled(EuiPanel)` + padding: 32px; +`; + export const CardSectionPanel = memo((props) => { - return ; + return ; }); CardSectionPanel.displayName = 'CardSectionPanel'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_sub_header.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_sub_header.tsx index fd787c01e50ff..e502fd741115c 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_sub_header.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/card_sub_header.tsx @@ -13,10 +13,18 @@ import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; export type SubHeaderProps = TouchedByUsersProps & EffectScopeProps & - Pick; + Pick & { + loadingPoliciesList?: boolean; + }; export const CardSubHeader = memo( - ({ createdBy, updatedBy, policies, 'data-test-subj': dataTestSubj }) => { + ({ + createdBy, + updatedBy, + policies, + loadingPoliciesList = false, + 'data-test-subj': dataTestSubj, + }) => { const getTestId = useTestIdGenerator(dataTestSubj); return ( @@ -29,7 +37,11 @@ export const CardSubHeader = memo( />
- +
); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx index 4557a7339d3d5..743eac7a15458 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/criteria_conditions.tsx @@ -50,6 +50,10 @@ const EuiFlexItemNested = styled(EuiFlexItem)` margin-top: 6px !important; `; +const StyledCondition = styled('span')` + margin-right: 6px; +`; + export type CriteriaConditionsProps = Pick & Pick; @@ -112,7 +116,11 @@ export const CriteriaConditions = memo( {entries.map(({ field, type, value, entries: nestedEntries = [] }) => { return (
- + {CONDITION_AND}} + value={field} + color="subdued" + /> { date: FormattedRelativePreferenceDateProps['value']; type: 'update' | 'create'; @@ -25,10 +30,15 @@ export const DateFieldValue = memo( const getTestId = useTestIdGenerator(dataTestSubj); return ( - - + + - + ` // This should dispaly it as "Applied t o 3 policies", but NOT as a menu with links +const StyledWithContextMenuShiftedWrapper = styled('div')` + margin-left: -10px; +`; + +const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + height: 10px !important; +`; export interface EffectScopeProps extends Pick { /** If set (even if empty), then effect scope will be policy specific. Else, it shows as global */ policies?: ContextMenuItemNavByRouterProps[]; + loadingPoliciesList?: boolean; } export const EffectScope = memo( - ({ policies, 'data-test-subj': dataTestSubj }) => { + ({ policies, loadingPoliciesList = false, 'data-test-subj': dataTestSubj }) => { const getTestId = useTestIdGenerator(dataTestSubj); const [icon, label] = useMemo(() => { @@ -43,18 +57,24 @@ export const EffectScope = memo( data-test-subj={dataTestSubj} > - + - {label} + {label} ); return policies && policies.length ? ( - - {effectiveScopeLabel} - + + + {effectiveScopeLabel} + + ) : ( effectiveScopeLabel ); @@ -65,22 +85,40 @@ EffectScope.displayName = 'EffectScope'; type WithContextMenuProps = Pick & PropsWithChildren<{ policies: Required['policies']; - }>; + }> & { + loadingPoliciesList?: boolean; + }; export const WithContextMenu = memo( - ({ policies, children, 'data-test-subj': dataTestSubj }) => { + ({ policies, loadingPoliciesList = false, children, 'data-test-subj': dataTestSubj }) => { const getTestId = useTestIdGenerator(dataTestSubj); + const hoverInfo = useMemo( + () => ( + + + + ), + [] + ); return ( 1 ? 'rightCenter' : 'rightUp'} data-test-subj={dataTestSubj} + loading={loadingPoliciesList} + hoverInfo={hoverInfo} button={ - + {children} } + title={POLICY_EFFECT_SCOPE_TITLE(policies.length)} /> ); } diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/text_value_display.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/text_value_display.tsx index 3843d7992bdf2..b7e085a1f43c2 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/text_value_display.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/text_value_display.tsx @@ -12,23 +12,26 @@ import classNames from 'classnames'; export type TextValueDisplayProps = PropsWithChildren<{ bold?: boolean; truncate?: boolean; + size?: 'xs' | 's' | 'm' | 'relative'; }>; /** * Common component for displaying consistent text across the card. Changes here could impact all of * display of values on the card */ -export const TextValueDisplay = memo(({ bold, truncate, children }) => { - const cssClassNames = useMemo(() => { - return classNames({ - 'eui-textTruncate': truncate, - }); - }, [truncate]); +export const TextValueDisplay = memo( + ({ bold, truncate, size = 's', children }) => { + const cssClassNames = useMemo(() => { + return classNames({ + 'eui-textTruncate': truncate, + }); + }, [truncate]); - return ( - - {bold ? {children} : children} - - ); -}); + return ( + + {bold ? {children} : children} + + ); + } +); TextValueDisplay.displayName = 'TextValueDisplay'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/touched_by_users.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/touched_by_users.tsx index d897b6caaa45d..6d9be470108f6 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/touched_by_users.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/touched_by_users.tsx @@ -7,10 +7,15 @@ import React, { memo } from 'react'; import { CommonProps, EuiAvatar, EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; import { CREATED_BY, LAST_UPDATED_BY } from './translations'; import { TextValueDisplay } from './text_value_display'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; +const StyledEuiFlexItem = styled(EuiFlexItem)` + margin: 6px; +`; + export interface TouchedByUsersProps extends Pick { createdBy: string; updatedBy: string; @@ -59,10 +64,10 @@ const UserName = memo(({ label, value, 'data-test-subj': dataTest responsive={false} data-test-subj={dataTestSubj} > - + {label} - - + + @@ -75,7 +80,7 @@ const UserName = memo(({ label, value, 'data-test-subj': dataTest {value} - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts index 512724b66d50e..4cdae5238a1ac 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/components/translations.ts @@ -39,6 +39,14 @@ export const POLICY_EFFECT_SCOPE = (policyCount = 0) => { }); }; +export const POLICY_EFFECT_SCOPE_TITLE = (policyCount = 0) => + i18n.translate('xpack.securitySolution.artifactCard.policyEffectScope.title', { + defaultMessage: 'Applied to the following {count, plural, one {policy} other {policies}}', + values: { + count: policyCount, + }, + }); + export const CONDITION_OPERATOR_TYPE_MATCH = i18n.translate( 'xpack.securitySolution.artifactCard.conditions.matchOperator', { diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx index b955d9fe71db7..1a410c977b0d2 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_item_nav_by_router.tsx @@ -6,7 +6,13 @@ */ import React, { memo } from 'react'; -import { EuiContextMenuItem, EuiContextMenuItemProps } from '@elastic/eui'; +import { + EuiContextMenuItem, + EuiContextMenuItemProps, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import styled from 'styled-components'; import { NavigateToAppOptions } from 'kibana/public'; import { useNavigateToAppEventHandler } from '../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { useTestIdGenerator } from '../hooks/use_test_id_generator'; @@ -22,15 +28,42 @@ export interface ContextMenuItemNavByRouterProps extends EuiContextMenuItemProps * is set on the menu component, this prop will be overridden */ textTruncate?: boolean; + /** Displays an additional info when hover an item */ + hoverInfo?: React.ReactNode; children: React.ReactNode; } +const StyledEuiContextMenuItem = styled(EuiContextMenuItem)` + .additional-info { + display: none; + } + &:hover { + .additional-info { + display: block !important; + } + } +`; + +const StyledEuiFlexItem = styled('div')` + max-width: 50%; + padding-right: 10px; +`; + /** * Just like `EuiContextMenuItem`, but allows for additional props to be defined which will * allow navigation to a URL path via React Router */ + export const ContextMenuItemNavByRouter = memo( - ({ navigateAppId, navigateOptions, onClick, textTruncate, children, ...otherMenuItemProps }) => { + ({ + navigateAppId, + navigateOptions, + onClick, + textTruncate, + hoverInfo, + children, + ...otherMenuItemProps + }) => { const handleOnClickViaNavigateToApp = useNavigateToAppEventHandler(navigateAppId ?? '', { ...navigateOptions, onClick, @@ -38,25 +71,37 @@ export const ContextMenuItemNavByRouter = memo( const getTestId = useTestIdGenerator(otherMenuItemProps['data-test-subj']); return ( - - {textTruncate ? ( -
- {children} -
- ) : ( - children - )} -
+ + {textTruncate ? ( + <> +
+ {children} +
+ {hoverInfo && ( + {hoverInfo} + )} + + ) : ( + <> + {children} + {hoverInfo && ( + {hoverInfo} + )} + + )} +
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.test.tsx b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.test.tsx index 8efa320c1789f..ae343a57c734f 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.test.tsx @@ -130,4 +130,23 @@ describe('When using the ContextMenuWithRouterSupport component', () => { expect.objectContaining({ path: '/one/two/three' }) ); }); + + it('should display loading state', () => { + render({ loading: true }); + clickMenuTriggerButton(); + expect(renderResult.getByTestId('testMenu-item-loading-1')).not.toBeNull(); + expect(renderResult.getByTestId('testMenu-item-loading-2')).not.toBeNull(); + }); + + it('should display view details button when prop', () => { + render({ hoverInfo: 'test' }); + clickMenuTriggerButton(); + expect(renderResult.getByTestId('testMenu-item-1').textContent).toEqual('click me 2test'); + }); + + it("shouldn't display view details button when no prop", () => { + render(); + clickMenuTriggerButton(); + expect(renderResult.getByTestId('testMenu-item-1').textContent).toEqual('click me 2'); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx index 6e33ad9218bb6..cc9b652ff9344 100644 --- a/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx +++ b/x-pack/plugins/security_solution/public/management/components/context_menu_with_router_support/context_menu_with_router_support.tsx @@ -12,6 +12,8 @@ import { EuiContextMenuPanelProps, EuiPopover, EuiPopoverProps, + EuiPopoverTitle, + EuiLoadingContent, } from '@elastic/eui'; import uuid from 'uuid'; import { @@ -30,6 +32,16 @@ export interface ContextMenuWithRouterSupportProps * overwritten to `true`. Setting this prop's value to `undefined` will suppress the default behaviour. */ maxWidth?: CSSProperties['maxWidth']; + /** + * The max height for the popup menu. Default is `255px`. + */ + maxHeight?: CSSProperties['maxHeight']; + /** + * It makes the panel scrollable + */ + title?: string; + loading?: boolean; + hoverInfo?: React.ReactNode; } /** @@ -38,7 +50,18 @@ export interface ContextMenuWithRouterSupportProps * Menu also supports automatically closing the popup when an item is clicked. */ export const ContextMenuWithRouterSupport = memo( - ({ items, button, panelPaddingSize, anchorPosition, maxWidth = '32ch', ...commonProps }) => { + ({ + items, + button, + panelPaddingSize, + anchorPosition, + maxWidth = '32ch', + maxHeight = '255px', + title, + loading = false, + hoverInfo, + ...commonProps + }) => { const getTestId = useTestIdGenerator(commonProps['data-test-subj']); const [isOpen, setIsOpen] = useState(false); @@ -51,12 +74,22 @@ export const ContextMenuWithRouterSupport = memo { return items.map((itemProps, index) => { + if (loading) { + return ( + + ); + } return ( { handleCloseMenu(); if (itemProps.onClick) { @@ -66,7 +99,7 @@ export const ContextMenuWithRouterSupport = memo ); }); - }, [getTestId, handleCloseMenu, items, maxWidth]); + }, [getTestId, handleCloseMenu, items, maxWidth, loading, hoverInfo]); type AdditionalPanelProps = Partial>; const additionalContextMenuPanelProps = useMemo(() => { @@ -79,8 +112,15 @@ export const ContextMenuWithRouterSupport = memo - + {title ? {title} : null} + ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index 0bf2195ba109f..a8d3cc1505463 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -253,11 +253,11 @@ describe('when rendering the PolicyTrustedAppsList', () => { expect( renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-0') .textContent - ).toEqual('Endpoint Policy 0'); + ).toEqual('Endpoint Policy 0View details'); expect( renderResult.getByTestId('policyTrustedAppsGrid-card-header-effectScope-popupMenu-item-1') .textContent - ).toEqual('Endpoint Policy 1'); + ).toEqual('Endpoint Policy 1View details'); }); it('should navigate to policy details when clicking policy on assignment context menu', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index b8c2018cd8787..c780f48000879 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -228,6 +228,11 @@ export const listOfPolicies: ( return isLoadedResourceState(policies) ? policies.data.items : []; }); +export const isLoadingListOfPolicies: (state: Immutable) => boolean = + createSelector(policiesState, (policies) => { + return isLoadingResourceState(policies); + }); + export const getMapOfPoliciesById: ( state: Immutable ) => Immutable>> = createSelector( diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap index cc7ae7fe1a658..0b1b5d4c5675f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_apps_grid/__snapshots__/index.test.tsx.snap @@ -385,10 +385,22 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` position: relative; } +.c5 { + padding-top: 2px; +} + +.c6 { + margin: 6px; +} + .c3.artifactEntryCard + .c2.artifactEntryCard { margin-top: 24px; } +.c4 { + padding: 32px; +} + .c0 .trusted-app + .trusted-app { margin-top: 24px; } @@ -411,17 +423,17 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = ` data-test-subj="trustedAppCard" >
Applied globally
@@ -731,7 +743,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -1114,7 +1126,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -1497,7 +1509,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -1880,7 +1892,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -2263,7 +2275,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -2646,7 +2658,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -3029,7 +3041,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -3412,7 +3424,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -3795,7 +3807,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -4178,7 +4190,7 @@ exports[`TrustedAppsGrid renders correctly when loaded data 1`] = `
Applied globally
@@ -4856,7 +4880,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -5239,7 +5263,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -5622,7 +5646,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -6005,7 +6029,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -6388,7 +6412,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -6771,7 +6795,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -7154,7 +7178,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -7537,7 +7561,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -7920,7 +7944,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -8303,7 +8327,7 @@ exports[`TrustedAppsGrid renders correctly when loading data for the second time
Applied globally
@@ -8938,7 +8974,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -9321,7 +9357,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -9704,7 +9740,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -10087,7 +10123,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -10470,7 +10506,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -10853,7 +10889,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -11236,7 +11272,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -11619,7 +11655,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -12002,7 +12038,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
Applied globally
@@ -12385,7 +12421,7 @@ exports[`TrustedAppsGrid renders correctly when new page and page size set (not
{ const error = useTrustedAppsSelector(getListErrorMessage); const location = useTrustedAppsSelector(getCurrentLocation); const policyListById = useTrustedAppsSelector(getMapOfPoliciesById); + const loadingPoliciesList = useTrustedAppsSelector(isLoadingListOfPolicies); const handlePaginationChange: PaginatedContentProps< TrustedApp, @@ -129,13 +131,13 @@ export const TrustedAppsGrid = memo(() => { }; policyToNavOptionsMap[policyId] = { - navigateAppId: APP_ID, navigateOptions: { path: policyDetailsPath, state: routeState, }, href: getAppUrl({ path: policyDetailsPath }), children: policyListById[policyId]?.name ?? policyId, + target: '_blank', }; return policyToNavOptionsMap; }, {}); @@ -144,6 +146,7 @@ export const TrustedAppsGrid = memo(() => { cachedCardProps[trustedApp.id] = { item: trustedApp, policies, + loadingPoliciesList, hideComments: true, 'data-test-subj': 'trustedAppCard', actions: [ @@ -177,7 +180,7 @@ export const TrustedAppsGrid = memo(() => { } return cachedCardProps; - }, [dispatch, getAppUrl, history, listItems, location, policyListById]); + }, [dispatch, getAppUrl, history, listItems, location, policyListById, loadingPoliciesList]); const handleArtifactCardProps = useCallback( (trustedApp: TrustedApp) => { From e7b60a53dd04fa66f74bc8bacd2118392e6dec49 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 13:03:45 -0400 Subject: [PATCH 08/54] [Fleet] Sample data and copy tweaks (#115078) (#115382) Co-authored-by: Thomas Neirynck --- .../server/language_clients/index.ts | 39 +++++-------------- .../custom_integrations/server/plugin.test.ts | 39 +++++-------------- .../sample_data/data_sets/logs/index.ts | 3 +- .../lib/register_with_integrations.ts | 25 ++++++------ .../sample_data/sample_data_registry.test.ts | 24 ++++++++++-- .../sample_data/sample_data_registry.ts | 20 ++++------ .../apis/custom_integration/integrations.ts | 10 ++--- .../data_visualizer/common/constants.ts | 3 -- .../data_visualizer/public/register_home.ts | 12 ++---- .../server/register_custom_integration.ts | 8 +++- .../integrations/layouts/default.tsx | 2 +- .../epm/components/integration_preference.tsx | 2 +- .../epm/screens/home/available_packages.tsx | 2 +- .../layer_template.test.tsx.snap | 4 +- .../layer_template.tsx | 2 +- .../maps/server/tutorials/ems/index.ts | 4 +- .../test/functional/page_objects/gis_page.ts | 2 +- 17 files changed, 82 insertions(+), 119 deletions(-) diff --git a/src/plugins/custom_integrations/server/language_clients/index.ts b/src/plugins/custom_integrations/server/language_clients/index.ts index 533d3237d4abf..96c8c4dc342b3 100644 --- a/src/plugins/custom_integrations/server/language_clients/index.ts +++ b/src/plugins/custom_integrations/server/language_clients/index.ts @@ -23,18 +23,6 @@ interface LanguageIntegration { const ELASTIC_WEBSITE_URL = 'https://www.elastic.co'; const ELASTICSEARCH_CLIENT_URL = `${ELASTIC_WEBSITE_URL}/guide/en/elasticsearch/client`; export const integrations: LanguageIntegration[] = [ - { - id: 'all', - title: i18n.translate('customIntegrations.languageclients.AllTitle', { - defaultMessage: 'Elasticsearch Clients', - }), - euiIconName: 'logoElasticsearch', - description: i18n.translate('customIntegrations.languageclients.AllDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official language clients.', - }), - docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/index.html`, - }, { id: 'javascript', title: i18n.translate('customIntegrations.languageclients.JavascriptTitle', { @@ -42,8 +30,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'nodejs.svg', description: i18n.translate('customIntegrations.languageclients.JavascriptDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Node.js client.', + defaultMessage: 'Index data to Elasticsearch with the JavaScript client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/javascript-api/{branch}/introduction.html`, }, @@ -54,8 +41,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'ruby.svg', description: i18n.translate('customIntegrations.languageclients.RubyDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Ruby client.', + defaultMessage: 'Index data to Elasticsearch with the Ruby client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/ruby-api/{branch}/ruby_client.html`, }, @@ -66,8 +52,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'go.svg', description: i18n.translate('customIntegrations.languageclients.GoDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Go client.', + defaultMessage: 'Index data to Elasticsearch with the Go client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/go-api/{branch}/overview.html`, }, @@ -78,8 +63,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'dotnet.svg', description: i18n.translate('customIntegrations.languageclients.DotNetDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official .NET client.', + defaultMessage: 'Index data to Elasticsearch with the .NET client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/net-api/{branch}/index.html`, }, @@ -90,8 +74,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'php.svg', description: i18n.translate('customIntegrations.languageclients.PhpDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official .PHP client.', + defaultMessage: 'Index data to Elasticsearch with the PHP client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/php-api/{branch}/index.html`, }, @@ -102,8 +85,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'perl.svg', description: i18n.translate('customIntegrations.languageclients.PerlDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Perl client.', + defaultMessage: 'Index data to Elasticsearch with the Perl client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/perl-api/current/index.html`, }, @@ -114,8 +96,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'python.svg', description: i18n.translate('customIntegrations.languageclients.PythonDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Python client.', + defaultMessage: 'Index data to Elasticsearch with the Python client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/python-api/{branch}/index.html`, }, @@ -126,8 +107,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'rust.svg', description: i18n.translate('customIntegrations.languageclients.RustDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Rust client.', + defaultMessage: 'Index data to Elasticsearch with the Rust client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/rust-api/current/index.html`, }, @@ -138,8 +118,7 @@ export const integrations: LanguageIntegration[] = [ }), icon: 'java.svg', description: i18n.translate('customIntegrations.languageclients.JavaDescription', { - defaultMessage: - 'Start building your custom application on top of Elasticsearch with the official Java client.', + defaultMessage: 'Index data to Elasticsearch with the Java client.', }), docUrlTemplate: `${ELASTICSEARCH_CLIENT_URL}/java-api-client/{branch}/index.html`, }, diff --git a/src/plugins/custom_integrations/server/plugin.test.ts b/src/plugins/custom_integrations/server/plugin.test.ts index a006b9d4ed3dc..15d933bbbb78e 100644 --- a/src/plugins/custom_integrations/server/plugin.test.ts +++ b/src/plugins/custom_integrations/server/plugin.test.ts @@ -31,23 +31,10 @@ describe('CustomIntegrationsPlugin', () => { test('should register language clients', () => { const setup = new CustomIntegrationsPlugin(initContext).setup(mockCoreSetup); expect(setup.getAppendCustomIntegrations()).toEqual([ - { - id: 'language_client.all', - title: 'Elasticsearch Clients', - description: - 'Start building your custom application on top of Elasticsearch with the official language clients.', - type: 'ui_link', - shipper: 'language_clients', - uiInternalPath: 'https://www.elastic.co/guide/en/elasticsearch/client/index.html', - isBeta: false, - icons: [{ type: 'eui', src: 'logoElasticsearch' }], - categories: ['elastic_stack', 'custom', 'language_client'], - }, { id: 'language_client.javascript', title: 'Elasticsearch JavaScript Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Node.js client.', + description: 'Index data to Elasticsearch with the JavaScript client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -59,8 +46,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.ruby', title: 'Elasticsearch Ruby Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Ruby client.', + description: 'Index data to Elasticsearch with the Ruby client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -72,8 +58,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.go', title: 'Elasticsearch Go Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Go client.', + description: 'Index data to Elasticsearch with the Go client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -85,8 +70,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.dotnet', title: 'Elasticsearch .NET Client', - description: - 'Start building your custom application on top of Elasticsearch with the official .NET client.', + description: 'Index data to Elasticsearch with the .NET client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -98,8 +82,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.php', title: 'Elasticsearch PHP Client', - description: - 'Start building your custom application on top of Elasticsearch with the official .PHP client.', + description: 'Index data to Elasticsearch with the PHP client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -111,8 +94,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.perl', title: 'Elasticsearch Perl Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Perl client.', + description: 'Index data to Elasticsearch with the Perl client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -124,8 +106,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.python', title: 'Elasticsearch Python Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Python client.', + description: 'Index data to Elasticsearch with the Python client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -137,8 +118,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.rust', title: 'Elasticsearch Rust Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Rust client.', + description: 'Index data to Elasticsearch with the Rust client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: @@ -150,8 +130,7 @@ describe('CustomIntegrationsPlugin', () => { { id: 'language_client.java', title: 'Elasticsearch Java Client', - description: - 'Start building your custom application on top of Elasticsearch with the official Java client.', + description: 'Index data to Elasticsearch with the Java client.', type: 'ui_link', shipper: 'language_clients', uiInternalPath: diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index ac783c1a2aba6..43d42c2557431 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -20,6 +20,7 @@ const logsDescription = i18n.translate('home.sampleData.logsSpecDescription', { }); const initialAppLinks = [] as AppLinkSchema[]; +export const GLOBE_ICON_PATH = '/plugins/home/assets/sample_data_resources/logs/icon.svg'; export const logsSpecProvider = function (): SampleDatasetSchema { return { id: 'logs', @@ -42,6 +43,6 @@ export const logsSpecProvider = function (): SampleDatasetSchema { }, ], status: 'not_installed', - iconPath: '/plugins/home/assets/sample_data_resources/logs/icon.svg', + iconPath: GLOBE_ICON_PATH, }; }; diff --git a/src/plugins/home/server/services/sample_data/lib/register_with_integrations.ts b/src/plugins/home/server/services/sample_data/lib/register_with_integrations.ts index 96c62b040926c..e33cd58910fd6 100644 --- a/src/plugins/home/server/services/sample_data/lib/register_with_integrations.ts +++ b/src/plugins/home/server/services/sample_data/lib/register_with_integrations.ts @@ -7,29 +7,26 @@ */ import { CoreSetup } from 'kibana/server'; +import { i18n } from '@kbn/i18n'; import { CustomIntegrationsPluginSetup } from '../../../../../custom_integrations/server'; -import { SampleDatasetSchema } from './sample_dataset_schema'; import { HOME_APP_BASE_PATH } from '../../../../common/constants'; +import { GLOBE_ICON_PATH } from '../data_sets/logs'; export function registerSampleDatasetWithIntegration( customIntegrations: CustomIntegrationsPluginSetup, - core: CoreSetup, - sampleDataset: SampleDatasetSchema + core: CoreSetup ) { customIntegrations.registerCustomIntegration({ - id: sampleDataset.id, - title: sampleDataset.name, - description: sampleDataset.description, + id: 'sample_data_all', + title: i18n.translate('home.sampleData.customIntegrationsTitle', { + defaultMessage: 'Sample Data', + }), + description: i18n.translate('home.sampleData.customIntegrationsDescription', { + defaultMessage: 'Add sample data and assets to Elasticsearch and Kibana.', + }), uiInternalPath: `${HOME_APP_BASE_PATH}#/tutorial_directory/sampleData`, isBeta: false, - icons: sampleDataset.iconPath - ? [ - { - type: 'svg', - src: core.http.basePath.prepend(sampleDataset.iconPath), - }, - ] - : [], + icons: [{ type: 'svg', src: core.http.basePath.prepend(GLOBE_ICON_PATH) }], categories: ['sample_data'], shipper: 'sample_data', }); diff --git a/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts b/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts index 74c4d66c4fb02..3d836d233d72c 100644 --- a/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts +++ b/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts @@ -28,20 +28,36 @@ describe('SampleDataRegistry', () => { }); describe('setup', () => { - test('should register the three sample datasets', () => { + let sampleDataRegistry: SampleDataRegistry; + beforeEach(() => { const initContext = coreMock.createPluginInitializerContext(); - const plugin = new SampleDataRegistry(initContext); - plugin.setup( + sampleDataRegistry = new SampleDataRegistry(initContext); + }); + + test('should register the three sample datasets', () => { + const setup = sampleDataRegistry.setup( mockCoreSetup, mockUsageCollectionPluginSetup, mockCustomIntegrationsPluginSetup ); + const datasets = setup.getSampleDatasets(); + expect(datasets[0].id).toEqual('flights'); + expect(datasets[2].id).toEqual('ecommerce'); + expect(datasets[1].id).toEqual('logs'); + }); + + test('should register the three sample datasets as single card', () => { + sampleDataRegistry.setup( + mockCoreSetup, + mockUsageCollectionPluginSetup, + mockCustomIntegrationsPluginSetup + ); const ids: string[] = mockCustomIntegrationsPluginSetup.registerCustomIntegration.mock.calls.map((args) => { return args[0].id; }); - expect(ids).toEqual(['flights', 'logs', 'ecommerce']); + expect(ids).toEqual(['sample_data_all']); }); }); }); diff --git a/src/plugins/home/server/services/sample_data/sample_data_registry.ts b/src/plugins/home/server/services/sample_data/sample_data_registry.ts index f966a05c12397..b88f42ca970af 100644 --- a/src/plugins/home/server/services/sample_data/sample_data_registry.ts +++ b/src/plugins/home/server/services/sample_data/sample_data_registry.ts @@ -28,22 +28,13 @@ export class SampleDataRegistry { constructor(private readonly initContext: PluginInitializerContext) {} private readonly sampleDatasets: SampleDatasetSchema[] = []; - private registerSampleDataSet( - specProvider: SampleDatasetProvider, - core: CoreSetup, - customIntegrations?: CustomIntegrationsPluginSetup - ) { + private registerSampleDataSet(specProvider: SampleDatasetProvider) { let value: SampleDatasetSchema; try { value = sampleDataSchema.validate(specProvider()); } catch (error) { throw new Error(`Unable to register sample dataset spec because it's invalid. ${error}`); } - - if (customIntegrations && core) { - registerSampleDatasetWithIntegration(customIntegrations, core, value); - } - const defaultIndexSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { return savedObjectJson.type === 'index-pattern' && savedObjectJson.id === value.defaultIndex; }); @@ -86,9 +77,12 @@ export class SampleDataRegistry { ); createUninstallRoute(router, this.sampleDatasets, usageTracker); - this.registerSampleDataSet(flightsSpecProvider, core, customIntegrations); - this.registerSampleDataSet(logsSpecProvider, core, customIntegrations); - this.registerSampleDataSet(ecommerceSpecProvider, core, customIntegrations); + this.registerSampleDataSet(flightsSpecProvider); + this.registerSampleDataSet(logsSpecProvider); + this.registerSampleDataSet(ecommerceSpecProvider); + if (customIntegrations && core) { + registerSampleDatasetWithIntegration(customIntegrations, core); + } return { getSampleDatasets: () => this.sampleDatasets, diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index 816e360c5a30b..e4797b334a866 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,12 +22,12 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - // sample data - expect(resp.body.length).to.be.above(14); // at least the language clients + sample data + add data + expect(resp.body.length).to.be(12); - ['flights', 'logs', 'ecommerce'].forEach((sampleData) => { - expect(resp.body.findIndex((c: { id: string }) => c.id === sampleData)).to.be.above(-1); - }); + // Test for sample data card + expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( + -1 + ); }); }); diff --git a/x-pack/plugins/data_visualizer/common/constants.ts b/x-pack/plugins/data_visualizer/common/constants.ts index 5a3a1d8f2e5bf..cc661ca6ffeff 100644 --- a/x-pack/plugins/data_visualizer/common/constants.ts +++ b/x-pack/plugins/data_visualizer/common/constants.ts @@ -46,7 +46,4 @@ export const applicationPath = `/app/home#/tutorial_directory/${FILE_DATA_VIS_TA export const featureTitle = i18n.translate('xpack.dataVisualizer.title', { defaultMessage: 'Upload a file', }); -export const featureDescription = i18n.translate('xpack.dataVisualizer.description', { - defaultMessage: 'Import your own CSV, NDJSON, or log file.', -}); export const featureId = `file_data_visualizer`; diff --git a/x-pack/plugins/data_visualizer/public/register_home.ts b/x-pack/plugins/data_visualizer/public/register_home.ts index 4f4601ae76977..9338c93000ec9 100644 --- a/x-pack/plugins/data_visualizer/public/register_home.ts +++ b/x-pack/plugins/data_visualizer/public/register_home.ts @@ -9,13 +9,7 @@ import { i18n } from '@kbn/i18n'; import type { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { FileDataVisualizerWrapper } from './lazy_load_bundle/component_wrapper'; -import { - featureDescription, - featureTitle, - FILE_DATA_VIS_TAB_ID, - applicationPath, - featureId, -} from '../common'; +import { featureTitle, FILE_DATA_VIS_TAB_ID, applicationPath, featureId } from '../common'; export function registerHomeAddData(home: HomePublicPluginSetup) { home.addData.registerAddDataTab({ @@ -31,7 +25,9 @@ export function registerHomeFeatureCatalogue(home: HomePublicPluginSetup) { home.featureCatalogue.register({ id: featureId, title: featureTitle, - description: featureDescription, + description: i18n.translate('xpack.dataVisualizer.description', { + defaultMessage: 'Import your own CSV, NDJSON, or log file.', + }), icon: 'document', path: applicationPath, showOnHomePage: true, diff --git a/x-pack/plugins/data_visualizer/server/register_custom_integration.ts b/x-pack/plugins/data_visualizer/server/register_custom_integration.ts index 86aa3cd96d613..67be78277189b 100644 --- a/x-pack/plugins/data_visualizer/server/register_custom_integration.ts +++ b/x-pack/plugins/data_visualizer/server/register_custom_integration.ts @@ -5,14 +5,18 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { CustomIntegrationsPluginSetup } from '../../../../src/plugins/custom_integrations/server'; -import { applicationPath, featureDescription, featureId, featureTitle } from '../common'; +import { applicationPath, featureId, featureTitle } from '../common'; export function registerWithCustomIntegrations(customIntegrations: CustomIntegrationsPluginSetup) { customIntegrations.registerCustomIntegration({ id: featureId, title: featureTitle, - description: featureDescription, + description: i18n.translate('xpack.dataVisualizer.customIntegrationsDescription', { + defaultMessage: + 'Upload data from a CSV, TSV, JSON or other log file to Elasticsearch for analysis.', + }), uiInternalPath: applicationPath, isBeta: false, icons: [ diff --git a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx index 0c46e1af301cf..d6d6dedf753ef 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx @@ -41,7 +41,7 @@ export const DefaultLayout: React.FunctionComponent = memo(({ section, ch

diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx index ecc5c22c8d8ce..4634996d6bc73 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/integration_preference.tsx @@ -54,7 +54,7 @@ const title = ( const recommendedTooltip = ( ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx index 91b557d0db5b6..f5c521ebacf16 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/available_packages.tsx @@ -181,7 +181,7 @@ export const AvailablePackages: React.FC = memo(() => { let controls = [ - , + , ]; diff --git a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/__snapshots__/layer_template.test.tsx.snap b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/__snapshots__/layer_template.test.tsx.snap index 3a301a951ed57..47dadb1246b38 100644 --- a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/__snapshots__/layer_template.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/__snapshots__/layer_template.test.tsx.snap @@ -32,7 +32,7 @@ exports[`should render EMS UI when left source is BOUNDARIES_SOURCE.EMS 1`] = ` Array [ Object { "id": "EMS", - "label": "Administrative boundaries from Elastic Maps Service", + "label": "Administrative boundaries from the Elastic Maps Service", }, Object { "id": "ELASTICSEARCH", @@ -85,7 +85,7 @@ exports[`should render elasticsearch UI when left source is BOUNDARIES_SOURCE.EL Array [ Object { "id": "EMS", - "label": "Administrative boundaries from Elastic Maps Service", + "label": "Administrative boundaries from the Elastic Maps Service", }, Object { "id": "ELASTICSEARCH", diff --git a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.tsx b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.tsx index 5bd2b68e61bc4..dfca19dbb964b 100644 --- a/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.tsx +++ b/x-pack/plugins/maps/public/classes/layers/choropleth_layer_wizard/layer_template.tsx @@ -40,7 +40,7 @@ const BOUNDARIES_OPTIONS = [ { id: BOUNDARIES_SOURCE.EMS, label: i18n.translate('xpack.maps.choropleth.boundaries.ems', { - defaultMessage: 'Administrative boundaries from Elastic Maps Service', + defaultMessage: 'Administrative boundaries from the Elastic Maps Service', }), }, { diff --git a/x-pack/plugins/maps/server/tutorials/ems/index.ts b/x-pack/plugins/maps/server/tutorials/ems/index.ts index 94da7c6258faa..ba8720a7bc8eb 100644 --- a/x-pack/plugins/maps/server/tutorials/ems/index.ts +++ b/x-pack/plugins/maps/server/tutorials/ems/index.ts @@ -61,11 +61,11 @@ export function emsBoundariesSpecProvider({ return () => ({ id: 'emsBoundaries', name: i18n.translate('xpack.maps.tutorials.ems.nameTitle', { - defaultMessage: 'EMS Boundaries', + defaultMessage: 'Elastic Maps Service', }), category: TutorialsCategory.OTHER, shortDescription: i18n.translate('xpack.maps.tutorials.ems.shortDescription', { - defaultMessage: 'Administrative boundaries from Elastic Maps Service.', + defaultMessage: 'Administrative boundaries from the Elastic Maps Service.', }), longDescription: i18n.translate('xpack.maps.tutorials.ems.longDescription', { defaultMessage: diff --git a/x-pack/test/functional/page_objects/gis_page.ts b/x-pack/test/functional/page_objects/gis_page.ts index 002dc575e956b..cd20863065688 100644 --- a/x-pack/test/functional/page_objects/gis_page.ts +++ b/x-pack/test/functional/page_objects/gis_page.ts @@ -521,7 +521,7 @@ export class GisPageObject extends FtrService { } async selectEMSBoundariesSource() { - this.log.debug(`Select EMS boundaries source`); + this.log.debug(`Select Elastic Maps Service boundaries source`); await this.testSubjects.click('emsBoundaries'); } From 26c24ba087e2476d72ba6566b495905e60f2d128 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 18 Oct 2021 19:25:09 +0200 Subject: [PATCH 09/54] [Security Solution] Fix casing for host isolation exceptions name and hide search bar when no entries (#115349) (#115387) --- .../src/index.ts | 4 +-- .../store/reducer.test.ts | 2 +- .../view/components/delete_modal.test.tsx | 4 +-- .../view/components/delete_modal.tsx | 6 ++-- .../view/components/empty.tsx | 4 +-- .../view/components/form.tsx | 2 +- .../view/components/form_flyout.tsx | 8 ++--- .../view/components/translations.ts | 2 +- .../host_isolation_exceptions_list.test.tsx | 15 +++++++++ .../view/host_isolation_exceptions_list.tsx | 31 +++++++++++-------- .../host_isolation_exceptions/index.ts | 2 +- 11 files changed, 50 insertions(+), 30 deletions(-) diff --git a/packages/kbn-securitysolution-list-constants/src/index.ts b/packages/kbn-securitysolution-list-constants/src/index.ts index 8f5ea4668e00a..f0e09ff7bb461 100644 --- a/packages/kbn-securitysolution-list-constants/src/index.ts +++ b/packages/kbn-securitysolution-list-constants/src/index.ts @@ -73,6 +73,6 @@ export const ENDPOINT_EVENT_FILTERS_LIST_DESCRIPTION = 'Endpoint Security Event export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID = 'endpoint_host_isolation_exceptions'; export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_NAME = - 'Endpoint Security Host Isolation Exceptions List'; + 'Endpoint Security Host isolation exceptions List'; export const ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_DESCRIPTION = - 'Endpoint Security Host Isolation Exceptions List'; + 'Endpoint Security Host isolation exceptions List'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts index 98b459fac41d3..17708516763bd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -13,7 +13,7 @@ import { hostIsolationExceptionsPageReducer } from './reducer'; import { getCurrentLocation } from './selector'; import { createEmptyHostIsolationException } from '../utils'; -describe('Host Isolation Exceptions Reducer', () => { +describe('Host isolation exceptions Reducer', () => { let initialState: HostIsolationExceptionsPageState; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx index 2118a8de9b9ed..9cca87bf61d6a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.test.tsx @@ -104,7 +104,7 @@ describe('When on the host isolation exceptions delete modal', () => { }); expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( - '"some name" has been removed from the Host Isolation Exceptions list.' + '"some name" has been removed from the Host isolation exceptions list.' ); }); @@ -129,7 +129,7 @@ describe('When on the host isolation exceptions delete modal', () => { }); expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( - 'Unable to remove "some name" from the Host Isolation Exceptions list. Reason: That\'s not true. That\'s impossible' + 'Unable to remove "some name" from the Host isolation exceptions list. Reason: That\'s not true. That\'s impossible' ); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx index 61b0bb7f930c3..51e0ab5a5a154 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/delete_modal.tsx @@ -54,7 +54,7 @@ export const HostIsolationExceptionDeleteModal = memo<{}>(() => { i18n.translate( 'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteSuccess', { - defaultMessage: '"{name}" has been removed from the Host Isolation Exceptions list.', + defaultMessage: '"{name}" has been removed from the Host isolation exceptions list.', values: { name: exception?.name }, } ) @@ -72,7 +72,7 @@ export const HostIsolationExceptionDeleteModal = memo<{}>(() => { 'xpack.securitySolution.hostIsolationExceptions.deletionDialog.deleteFailure', { defaultMessage: - 'Unable to remove "{name}" from the Host Isolation Exceptions list. Reason: {message}', + 'Unable to remove "{name}" from the Host isolation exceptions list. Reason: {message}', values: { name: exception?.name, message: deleteError.message }, } ) @@ -86,7 +86,7 @@ export const HostIsolationExceptionDeleteModal = memo<{}>(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx index eb53268a9fbd8..88cd0abc365cf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx @@ -25,7 +25,7 @@ export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({

} @@ -39,7 +39,7 @@ export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({ } diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx index 7b13df16da483..01fe8583bae60 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx @@ -179,7 +179,7 @@ export const HostIsolationExceptionsForm: React.FC<{ diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx index 14e976226c470..e87ac2adeab49 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx @@ -181,12 +181,12 @@ export const HostIsolationExceptionsFormFlyout: React.FC<{}> = memo(() => { {exception?.item_id ? ( ) : ( )} @@ -206,14 +206,14 @@ export const HostIsolationExceptionsFormFlyout: React.FC<{}> = memo(() => {

) : (

)} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts index 207e094453d90..69f2c7809a52a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts @@ -32,7 +32,7 @@ export const NAME_ERROR = i18n.translate( export const DESCRIPTION_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.hostIsolationExceptions.form.description.placeholder', { - defaultMessage: 'Describe your Host Isolation Exception', + defaultMessage: 'Describe your Host isolation exception', } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index a7dfb66b02235..5113457e5bccc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -67,11 +67,18 @@ describe('When on the host isolation exceptions page', () => { await dataReceived(); expect(renderResult.getByTestId('hostIsolationExceptionsEmpty')).toBeTruthy(); }); + + it('should not display the search bar', async () => { + render(); + await dataReceived(); + expect(renderResult.queryByTestId('searchExceptions')).toBeFalsy(); + }); }); describe('And data exists', () => { beforeEach(async () => { getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); }); + it('should show loading indicator while retrieving data', async () => { let releaseApiResponse: (value?: unknown) => void; @@ -88,6 +95,12 @@ describe('When on the host isolation exceptions page', () => { expect(renderResult.container.querySelector('.euiProgress')).toBeNull(); }); + it('should display the search bar', async () => { + render(); + await dataReceived(); + expect(renderResult.getByTestId('searchExceptions')).toBeTruthy(); + }); + it('should show items on the list', async () => { render(); await dataReceived(); @@ -113,6 +126,7 @@ describe('When on the host isolation exceptions page', () => { ).toEqual(' Server is too far away'); }); }); + describe('is license platinum plus', () => { beforeEach(() => { isPlatinumPlusMock.mockReturnValue(true); @@ -131,6 +145,7 @@ describe('When on the host isolation exceptions page', () => { expect(renderResult.queryByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); }); }); + describe('is not license platinum plus', () => { beforeEach(() => { isPlatinumPlusMock.mockReturnValue(false); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index f4a89e89a9f67..096575bab360c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -127,7 +127,7 @@ export const HostIsolationExceptionsList = () => { title={ } actions={ @@ -141,7 +141,7 @@ export const HostIsolationExceptionsList = () => { > ) : ( @@ -151,18 +151,23 @@ export const HostIsolationExceptionsList = () => { > {showFlyout && } - - {itemToDelete ? : null} + + {listItems.length ? ( + + ) : null} + + + items={listItems} ItemComponent={ArtifactEntryCard} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts index b9b70c4c1da19..15f0b2f65cb95 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/host_isolation_exceptions/index.ts @@ -31,7 +31,7 @@ export const cli = () => { } }, { - description: 'Load Host Isolation Exceptions', + description: 'Load Host isolation exceptions', flags: { string: ['kibana'], default: { From 8df988e0fbc5f2d0a412c464b895ae2670863f5b Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Mon, 18 Oct 2021 18:29:27 +0100 Subject: [PATCH 10/54] Fix test errors (#115183) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/es_ui_shared/public/request/use_request.ts | 9 +++++++-- .../deprecation_types/reindex/flyout/step_progress.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/plugins/es_ui_shared/public/request/use_request.ts b/src/plugins/es_ui_shared/public/request/use_request.ts index 33085bdbf4478..1241d6222a38f 100644 --- a/src/plugins/es_ui_shared/public/request/use_request.ts +++ b/src/plugins/es_ui_shared/public/request/use_request.ts @@ -103,8 +103,13 @@ export const useRequest = ( : serializedResponseData; setData(responseData); } - // Setting isLoading to false also acts as a signal for scheduling the next poll request. - setIsLoading(false); + // There can be situations in which a component that consumes this hook gets unmounted when + // the request returns an error. So before changing the isLoading state, check if the component + // is still mounted. + if (isMounted.current === true) { + // Setting isLoading to false also acts as a signal for scheduling the next poll request. + setIsLoading(false); + } }, [requestBody, httpClient, deserializer, clearPollInterval] ); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/step_progress.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/step_progress.tsx index 2de21ad86f839..01b4fe4eb84fc 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/step_progress.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/step_progress.tsx @@ -63,7 +63,7 @@ const Step: React.FunctionComponent = ({
-

{title}

+
{title}
{children &&
{children}
}
From 62bdc2df2de8be36cef5e46adfcf57c3fcb353a1 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 13:32:49 -0400 Subject: [PATCH 11/54] [ML] Add context popover for APM latency correlations & failed transactions correlations (#113679) (#115391) * [ML] Add context popover, api tests, unit tests * [ML] Add sample size context * [ML] Fix translations * [ML] Add tooltip * [ML] Clean up & fix types * [ML] Add spacer, fix popover button * [ML] Add accented highlight, truncation, center alignment * [ML] Bold texts * Change color to primary, add tooltip * Take out sample * Update on add filter callback * Refactor requests body * Fix types, tests * Fix include * Remove isPopulatedObject completely * Fix top values Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Quynh Nguyen <43350163+qn895@users.noreply.github.com> --- .../failed_transactions_correlations/types.ts | 2 + .../search_strategies/field_stats_types.ts | 57 ++++++ .../latency_correlations/types.ts | 2 + .../context_popover/context_popover.tsx | 124 +++++++++++++ .../app/correlations/context_popover/index.ts | 8 + .../context_popover/top_values.tsx | 169 +++++++++++++++++ .../failed_transactions_correlations.tsx | 116 ++++++------ .../app/correlations/latency_correlations.tsx | 75 ++++++-- .../server/lib/search_strategies/constants.ts | 7 + ...ransactions_correlations_search_service.ts | 39 +++- ...tions_correlations_search_service_state.ts | 8 + .../latency_correlations_search_service.ts | 18 ++ ...tency_correlations_search_service_state.ts | 7 + .../field_stats/get_boolean_field_stats.ts | 87 +++++++++ .../field_stats/get_field_stats.test.ts | 170 ++++++++++++++++++ .../queries/field_stats/get_fields_stats.ts | 110 ++++++++++++ .../field_stats/get_keyword_field_stats.ts | 87 +++++++++ .../field_stats/get_numeric_field_stats.ts | 130 ++++++++++++++ .../utils/field_stats_utils.ts | 58 ++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../tests/correlations/failed_transactions.ts | 8 + .../tests/correlations/latency.ts | 9 + 23 files changed, 1214 insertions(+), 79 deletions(-) create mode 100644 x-pack/plugins/apm/common/search_strategies/field_stats_types.ts create mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx create mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts create mode 100644 x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_boolean_field_stats.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_fields_stats.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_keyword_field_stats.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_numeric_field_stats.ts create mode 100644 x-pack/plugins/apm/server/lib/search_strategies/utils/field_stats_utils.ts 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 857e1e9dbe95d..266d7246c35d4 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 @@ -13,6 +13,7 @@ import { } from '../types'; import { FAILED_TRANSACTIONS_IMPACT_THRESHOLD } from './constants'; +import { FieldStats } from '../field_stats_types'; export interface FailedTransactionsCorrelation extends FieldValuePair { doc_count: number; @@ -42,4 +43,5 @@ export interface FailedTransactionsCorrelationsRawResponse percentileThresholdValue?: number; overallHistogram?: HistogramItem[]; errorHistogram?: HistogramItem[]; + fieldStats?: FieldStats[]; } diff --git a/x-pack/plugins/apm/common/search_strategies/field_stats_types.ts b/x-pack/plugins/apm/common/search_strategies/field_stats_types.ts new file mode 100644 index 0000000000000..d96bb4408f0e8 --- /dev/null +++ b/x-pack/plugins/apm/common/search_strategies/field_stats_types.ts @@ -0,0 +1,57 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import { SearchStrategyParams } from './types'; + +export interface FieldStatsCommonRequestParams extends SearchStrategyParams { + samplerShardSize: number; +} + +export interface Field { + fieldName: string; + type: string; + cardinality: number; +} + +export interface Aggs { + [key: string]: estypes.AggregationsAggregationContainer; +} + +export interface TopValueBucket { + key: string | number; + doc_count: number; +} + +export interface TopValuesStats { + count?: number; + fieldName: string; + topValues: TopValueBucket[]; + topValuesSampleSize: number; + isTopValuesSampled?: boolean; + topValuesSamplerShardSize?: number; +} + +export interface NumericFieldStats extends TopValuesStats { + min: number; + max: number; + avg: number; + median?: number; +} + +export type KeywordFieldStats = TopValuesStats; + +export interface BooleanFieldStats { + fieldName: string; + count: number; + [key: string]: number | string; +} + +export type FieldStats = + | NumericFieldStats + | KeywordFieldStats + | BooleanFieldStats; 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 75d526202bb08..2eb2b37159459 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 @@ -11,6 +11,7 @@ import { RawResponseBase, SearchStrategyClientParams, } from '../types'; +import { FieldStats } from '../field_stats_types'; export interface LatencyCorrelation extends FieldValuePair { correlation: number; @@ -40,4 +41,5 @@ export interface LatencyCorrelationsRawResponse extends RawResponseBase { overallHistogram?: HistogramItem[]; percentileThresholdValue?: number; latencyCorrelations?: LatencyCorrelation[]; + fieldStats?: FieldStats[]; } diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx new file mode 100644 index 0000000000000..4a0f7d81e24dc --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/context_popover.tsx @@ -0,0 +1,124 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import React, { Fragment, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldStats } from '../../../../../common/search_strategies/field_stats_types'; +import { OnAddFilter, TopValues } from './top_values'; +import { useTheme } from '../../../../hooks/use_theme'; + +export function CorrelationsContextPopover({ + fieldName, + fieldValue, + topValueStats, + onAddFilter, +}: { + fieldName: string; + fieldValue: string | number; + topValueStats?: FieldStats; + onAddFilter: OnAddFilter; +}) { + const [infoIsOpen, setInfoOpen] = useState(false); + const theme = useTheme(); + + if (!topValueStats) return null; + + const popoverTitle = ( + + + +
{fieldName}
+
+
+
+ ); + + return ( + + ) => { + setInfoOpen(!infoIsOpen); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.topFieldValuesAriaLabel', + { + defaultMessage: 'Show top 10 field values', + } + )} + data-test-subj={'apmCorrelationsContextPopoverButton'} + style={{ marginLeft: theme.eui.paddingSizes.xs }} + /> + + } + isOpen={infoIsOpen} + closePopover={() => setInfoOpen(false)} + anchorPosition="rightCenter" + data-test-subj={'apmCorrelationsContextPopover'} + > + {popoverTitle} + +
+ {i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.fieldTopValuesLabel', + { + defaultMessage: 'Top 10 values', + } + )} +
+
+ {infoIsOpen ? ( + <> + + {topValueStats.topValuesSampleSize !== undefined && ( + + + + + + + )} + + ) : null} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts b/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts new file mode 100644 index 0000000000000..5588328da4452 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CorrelationsContextPopover } from './context_popover'; diff --git a/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx b/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx new file mode 100644 index 0000000000000..803b474fe7754 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/correlations/context_popover/top_values.tsx @@ -0,0 +1,169 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FieldStats } from '../../../../../common/search_strategies/field_stats_types'; +import { asPercent } from '../../../../../common/utils/formatters'; +import { useTheme } from '../../../../hooks/use_theme'; + +export type OnAddFilter = ({ + fieldName, + fieldValue, + include, +}: { + fieldName: string; + fieldValue: string | number; + include: boolean; +}) => void; + +interface Props { + topValueStats: FieldStats; + compressed?: boolean; + onAddFilter?: OnAddFilter; + fieldValue?: string | number; +} + +export function TopValues({ topValueStats, onAddFilter, fieldValue }: Props) { + const { topValues, topValuesSampleSize, count, fieldName } = topValueStats; + const theme = useTheme(); + + if (!Array.isArray(topValues) || topValues.length === 0) return null; + + const sampledSize = + typeof topValuesSampleSize === 'string' + ? parseInt(topValuesSampleSize, 10) + : topValuesSampleSize; + const progressBarMax = sampledSize ?? count; + return ( +
+ {Array.isArray(topValues) && + topValues.map((value) => { + const isHighlighted = + fieldValue !== undefined && value.key === fieldValue; + const barColor = isHighlighted ? 'accent' : 'primary'; + const valueText = + progressBarMax !== undefined + ? asPercent(value.doc_count, progressBarMax) + : undefined; + + return ( + <> + + + + + {value.key} + + } + className="eui-textTruncate" + aria-label={value.key.toString()} + valueText={valueText} + labelProps={ + isHighlighted + ? { + style: { fontWeight: 'bold' }, + } + : undefined + } + /> + + {fieldName !== undefined && + value.key !== undefined && + onAddFilter !== undefined ? ( + <> + { + onAddFilter({ + fieldName, + fieldValue: + typeof value.key === 'number' + ? value.key.toString() + : value.key, + include: true, + }); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.addFilterAriaLabel', + { + defaultMessage: 'Filter for {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`apmFieldContextTopValuesAddFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + width: theme.eui.euiSizeL, + paddingRight: 2, + paddingLeft: 2, + paddingTop: 0, + paddingBottom: 0, + }} + /> + { + onAddFilter({ + fieldName, + fieldValue: + typeof value.key === 'number' + ? value.key.toString() + : value.key, + include: false, + }); + }} + aria-label={i18n.translate( + 'xpack.apm.correlations.fieldContextPopover.removeFilterAriaLabel', + { + defaultMessage: 'Filter out {fieldName}: "{value}"', + values: { fieldName, value: value.key }, + } + )} + data-test-subj={`apmFieldContextTopValuesExcludeFilterButton-${value.key}-${value.key}`} + style={{ + minHeight: 'auto', + width: theme.eui.euiSizeL, + paddingTop: 0, + paddingBottom: 0, + paddingRight: 2, + paddingLeft: 2, + }} + /> + + ) : null} + + + ); + })} +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx index d73ed9d58e526..a177733b3ecaf 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/failed_transactions_correlations.tsx @@ -15,12 +15,10 @@ import { EuiFlexItem, EuiSpacer, EuiIcon, - EuiLink, EuiTitle, EuiBetaBadge, EuiBadge, EuiToolTip, - RIGHT_ALIGNMENT, EuiSwitch, EuiIconTip, } from '@elastic/eui'; @@ -45,7 +43,7 @@ import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { useSearchStrategy } from '../../../hooks/use_search_strategy'; import { ImpactBar } from '../../shared/ImpactBar'; -import { createHref, push } from '../../shared/Links/url_helpers'; +import { push } from '../../shared/Links/url_helpers'; import { CorrelationsTable } from './correlations_table'; import { FailedTransactionsCorrelationsHelpPopover } from './failed_transactions_correlations_help_popover'; @@ -62,6 +60,9 @@ import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_w import { CorrelationsProgressControls } from './progress_controls'; import { useLocalStorage } from '../../../hooks/useLocalStorage'; import { useTheme } from '../../../hooks/use_theme'; +import { CorrelationsContextPopover } from './context_popover'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; +import { OnAddFilter } from './context_popover/top_values'; export function FailedTransactionsCorrelations({ onFilter, @@ -81,6 +82,14 @@ export function FailedTransactionsCorrelations({ percentileThreshold: DEFAULT_PERCENTILE_THRESHOLD, } ); + + const fieldStats: Record | undefined = useMemo(() => { + return response.fieldStats?.reduce((obj, field) => { + obj[field.fieldName] = field; + return obj; + }, {} as Record); + }, [response?.fieldStats]); + const progressNormalized = progress.loaded / progress.total; const { overallHistogram, hasData, status } = getOverallHistogram( response, @@ -104,6 +113,28 @@ export function FailedTransactionsCorrelations({ setShowStats(!showStats); }, [setShowStats, showStats]); + const onAddFilter = useCallback( + ({ fieldName, fieldValue, include }) => { + if (include) { + push(history, { + query: { + kuery: `${fieldName}:"${fieldValue}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_include_filter' }); + } else { + push(history, { + query: { + kuery: `not ${fieldName}:"${fieldValue}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_exclude_filter' }); + } + onFilter(); + }, + [onFilter, history, trackApmEvent] + ); + const failedTransactionsCorrelationsColumns: Array< EuiBasicTableColumn > = useMemo(() => { @@ -227,7 +258,6 @@ export function FailedTransactionsCorrelations({ )} ), - align: RIGHT_ALIGNMENT, render: (_, { normalizedScore }) => { return ( <> @@ -264,6 +294,17 @@ export function FailedTransactionsCorrelations({ 'xpack.apm.correlations.failedTransactions.correlationsTable.fieldNameLabel', { defaultMessage: 'Field name' } ), + render: (_, { fieldName, fieldValue }) => ( + <> + {fieldName} + + + ), sortable: true, }, { @@ -290,15 +331,15 @@ export function FailedTransactionsCorrelations({ ), icon: 'plusInCircle', type: 'icon', - onClick: (term: FailedTransactionsCorrelation) => { - push(history, { - query: { - kuery: `${term.fieldName}:"${term.fieldValue}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_include_filter' }); - }, + onClick: ({ + fieldName, + fieldValue, + }: FailedTransactionsCorrelation) => + onAddFilter({ + fieldName, + fieldValue, + include: true, + }), }, { name: i18n.translate( @@ -311,49 +352,20 @@ export function FailedTransactionsCorrelations({ ), icon: 'minusInCircle', type: 'icon', - onClick: (term: FailedTransactionsCorrelation) => { - push(history, { - query: { - kuery: `not ${term.fieldName}:"${term.fieldValue}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_exclude_filter' }); - }, + onClick: ({ + fieldName, + fieldValue, + }: FailedTransactionsCorrelation) => + onAddFilter({ + fieldName, + fieldValue, + include: false, + }), }, ], - name: i18n.translate( - 'xpack.apm.correlations.correlationsTable.actionsLabel', - { defaultMessage: 'Filter' } - ), - render: (_, { fieldName, fieldValue }) => { - return ( - <> - - - -  /  - - - - - ); - }, }, ] as Array>; - }, [history, onFilter, trackApmEvent, showStats]); + }, [fieldStats, onAddFilter, showStats]); useEffect(() => { if (isErrorMessage(progress.error)) { diff --git a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx index 167df0fd10b40..75af7fae4ce12 100644 --- a/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx +++ b/x-pack/plugins/apm/public/components/app/correlations/latency_correlations.tsx @@ -53,6 +53,9 @@ import { CorrelationsLog } from './correlations_log'; import { CorrelationsEmptyStatePrompt } from './empty_state_prompt'; import { CrossClusterSearchCompatibilityWarning } from './cross_cluster_search_warning'; import { CorrelationsProgressControls } from './progress_controls'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; +import { CorrelationsContextPopover } from './context_popover'; +import { OnAddFilter } from './context_popover/top_values'; export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { const { @@ -74,6 +77,13 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { progress.isRunning ); + const fieldStats: Record | undefined = useMemo(() => { + return response.fieldStats?.reduce((obj, field) => { + obj[field.fieldName] = field; + return obj; + }, {} as Record); + }, [response?.fieldStats]); + useEffect(() => { if (isErrorMessage(progress.error)) { notifications.toasts.addDanger({ @@ -104,6 +114,28 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { const history = useHistory(); const trackApmEvent = useUiTracker({ app: 'apm' }); + const onAddFilter = useCallback( + ({ fieldName, fieldValue, include }) => { + if (include) { + push(history, { + query: { + kuery: `${fieldName}:"${fieldValue}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_include_filter' }); + } else { + push(history, { + query: { + kuery: `not ${fieldName}:"${fieldValue}"`, + }, + }); + trackApmEvent({ metric: 'correlations_term_exclude_filter' }); + } + onFilter(); + }, + [onFilter, history, trackApmEvent] + ); + const mlCorrelationColumns: Array> = useMemo( () => [ @@ -147,6 +179,17 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { 'xpack.apm.correlations.latencyCorrelations.correlationsTable.fieldNameLabel', { defaultMessage: 'Field name' } ), + render: (_, { fieldName, fieldValue }) => ( + <> + {fieldName} + + + ), sortable: true, }, { @@ -172,15 +215,12 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { ), icon: 'plusInCircle', type: 'icon', - onClick: (term: LatencyCorrelation) => { - push(history, { - query: { - kuery: `${term.fieldName}:"${term.fieldValue}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_include_filter' }); - }, + onClick: ({ fieldName, fieldValue }: LatencyCorrelation) => + onAddFilter({ + fieldName, + fieldValue, + include: true, + }), }, { name: i18n.translate( @@ -193,15 +233,12 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { ), icon: 'minusInCircle', type: 'icon', - onClick: (term: LatencyCorrelation) => { - push(history, { - query: { - kuery: `not ${term.fieldName}:"${term.fieldValue}"`, - }, - }); - onFilter(); - trackApmEvent({ metric: 'correlations_term_exclude_filter' }); - }, + onClick: ({ fieldName, fieldValue }: LatencyCorrelation) => + onAddFilter({ + fieldName, + fieldValue, + include: false, + }), }, ], name: i18n.translate( @@ -210,7 +247,7 @@ export function LatencyCorrelations({ onFilter }: { onFilter: () => void }) { ), }, ], - [history, onFilter, trackApmEvent] + [fieldStats, onAddFilter] ); const [sortField, setSortField] = diff --git a/x-pack/plugins/apm/server/lib/search_strategies/constants.ts b/x-pack/plugins/apm/server/lib/search_strategies/constants.ts index 5500e336c3542..5af1b21630720 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/constants.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/constants.ts @@ -81,3 +81,10 @@ export const CORRELATION_THRESHOLD = 0.3; export const KS_TEST_THRESHOLD = 0.1; export const ERROR_CORRELATION_THRESHOLD = 0.02; + +/** + * Field stats/top values sampling constants + */ + +export const SAMPLER_TOP_TERMS_THRESHOLD = 100000; +export const SAMPLER_TOP_TERMS_SHARD_SIZE = 5000; 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 239cf39f15ffe..af5e535abdc3f 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 @@ -36,6 +36,7 @@ import type { SearchServiceProvider } from '../search_strategy_provider'; import { failedTransactionsCorrelationsSearchServiceStateProvider } from './failed_transactions_correlations_search_service_state'; import { ERROR_CORRELATION_THRESHOLD } from '../constants'; +import { fetchFieldsStats } from '../queries/field_stats/get_fields_stats'; export type FailedTransactionsCorrelationsSearchServiceProvider = SearchServiceProvider< @@ -133,6 +134,7 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact state.setProgress({ loadedFieldCandidates: 1 }); let fieldCandidatesFetchedCount = 0; + const fieldsToSample = new Set(); if (params !== undefined && fieldCandidates.length > 0) { const batches = chunk(fieldCandidates, 10); for (let i = 0; i < batches.length; i++) { @@ -150,13 +152,19 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact results.forEach((result, idx) => { if (result.status === 'fulfilled') { + const significantCorrelations = result.value.filter( + (record) => + record && + record.pValue !== undefined && + record.pValue < ERROR_CORRELATION_THRESHOLD + ); + + significantCorrelations.forEach((r) => { + fieldsToSample.add(r.fieldName); + }); + state.addFailedTransactionsCorrelations( - result.value.filter( - (record) => - record && - typeof record.pValue === 'number' && - record.pValue < ERROR_CORRELATION_THRESHOLD - ) + significantCorrelations ); } else { // If one of the fields in the batch had an error @@ -184,6 +192,23 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact `Identified correlations for ${fieldCandidatesFetchedCount} fields out of ${fieldCandidates.length} candidates.` ); } + + addLogMessage( + `Identified ${fieldsToSample.size} fields to sample for field statistics.` + ); + + const { stats: fieldStats } = await fetchFieldsStats( + esClient, + params, + [...fieldsToSample], + [{ fieldName: EVENT_OUTCOME, fieldValue: EventOutcome.failure }] + ); + + addLogMessage( + `Retrieved field statistics for ${fieldStats.length} fields out of ${fieldsToSample.size} fields.` + ); + + state.addFieldStats(fieldStats); } catch (e) { state.setError(e); } @@ -208,6 +233,7 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact errorHistogram, percentileThresholdValue, progress, + fieldStats, } = state.getState(); return { @@ -231,6 +257,7 @@ export const failedTransactionsCorrelationsSearchServiceProvider: FailedTransact overallHistogram, errorHistogram, percentileThresholdValue, + fieldStats, }, }; }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service_state.ts b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service_state.ts index a2530ea8a400c..ed0fe5d6e178b 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service_state.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/failed_transactions_correlations/failed_transactions_correlations_search_service_state.ts @@ -8,6 +8,7 @@ import { FailedTransactionsCorrelation } from '../../../../common/search_strategies/failed_transactions_correlations/types'; import type { HistogramItem } from '../../../../common/search_strategies/types'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; interface Progress { started: number; @@ -73,6 +74,11 @@ export const failedTransactionsCorrelationsSearchServiceStateProvider = () => { }; } + const fieldStats: FieldStats[] = []; + function addFieldStats(stats: FieldStats[]) { + fieldStats.push(...stats); + } + const failedTransactionsCorrelations: FailedTransactionsCorrelation[] = []; function addFailedTransactionsCorrelation(d: FailedTransactionsCorrelation) { failedTransactionsCorrelations.push(d); @@ -98,6 +104,7 @@ export const failedTransactionsCorrelationsSearchServiceStateProvider = () => { percentileThresholdValue, progress, failedTransactionsCorrelations, + fieldStats, }; } @@ -115,6 +122,7 @@ export const failedTransactionsCorrelationsSearchServiceStateProvider = () => { setErrorHistogram, setPercentileThresholdValue, setProgress, + addFieldStats, }; }; 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 91f4a0d3349a4..4862f7dd1de1a 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 @@ -36,6 +36,7 @@ import { searchServiceLogProvider } from '../search_service_log'; 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, @@ -196,6 +197,7 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch `Loaded fractions and totalDocCount of ${totalDocCount}.` ); + const fieldsToSample = new Set(); let loadedHistograms = 0; for await (const item of fetchTransactionDurationHistograms( esClient, @@ -211,6 +213,7 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch )) { if (item !== undefined) { state.addLatencyCorrelation(item); + fieldsToSample.add(item.fieldName); } loadedHistograms++; state.setProgress({ @@ -225,6 +228,19 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch fieldValuePairs.length } field/value pairs.` ); + + addLogMessage( + `Identified ${fieldsToSample.size} fields to sample for field statistics.` + ); + + const { stats: fieldStats } = await fetchFieldsStats(esClient, params, [ + ...fieldsToSample, + ]); + + addLogMessage( + `Retrieved field statistics for ${fieldStats.length} fields out of ${fieldsToSample.size} fields.` + ); + state.addFieldStats(fieldStats); } catch (e) { state.setError(e); } @@ -251,6 +267,7 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch overallHistogram, percentileThresholdValue, progress, + fieldStats, } = state.getState(); return { @@ -270,6 +287,7 @@ export const latencyCorrelationsSearchServiceProvider: LatencyCorrelationsSearch state.getLatencyCorrelationsSortedByCorrelation(), percentileThresholdValue, overallHistogram, + fieldStats, }, }; }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service_state.ts b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service_state.ts index 53f357ed1135f..186099e4c307a 100644 --- a/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service_state.ts +++ b/x-pack/plugins/apm/server/lib/search_strategies/latency_correlations/latency_correlations_search_service_state.ts @@ -10,6 +10,7 @@ import type { LatencyCorrelationSearchServiceProgress, LatencyCorrelation, } from '../../../../common/search_strategies/latency_correlations/types'; +import { FieldStats } from '../../../../common/search_strategies/field_stats_types'; export const latencyCorrelationsSearchServiceStateProvider = () => { let ccsWarning = false; @@ -79,6 +80,10 @@ export const latencyCorrelationsSearchServiceStateProvider = () => { function getLatencyCorrelationsSortedByCorrelation() { return latencyCorrelations.sort((a, b) => b.correlation - a.correlation); } + const fieldStats: FieldStats[] = []; + function addFieldStats(stats: FieldStats[]) { + fieldStats.push(...stats); + } function getState() { return { @@ -90,6 +95,7 @@ export const latencyCorrelationsSearchServiceStateProvider = () => { percentileThresholdValue, progress, latencyCorrelations, + fieldStats, }; } @@ -106,6 +112,7 @@ export const latencyCorrelationsSearchServiceStateProvider = () => { setOverallHistogram, setPercentileThresholdValue, setProgress, + addFieldStats, }; }; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_boolean_field_stats.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_boolean_field_stats.ts new file mode 100644 index 0000000000000..551ecfe3cd4ea --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_boolean_field_stats.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { SearchRequest } from '@elastic/elasticsearch/api/types'; +import { estypes } from '@elastic/elasticsearch'; +import { buildSamplerAggregation } from '../../utils/field_stats_utils'; +import { FieldValuePair } from '../../../../../common/search_strategies/types'; +import { + FieldStatsCommonRequestParams, + BooleanFieldStats, + Aggs, + TopValueBucket, +} from '../../../../../common/search_strategies/field_stats_types'; +import { getQueryWithParams } from '../get_query_with_params'; + +export const getBooleanFieldStatsRequest = ( + params: FieldStatsCommonRequestParams, + fieldName: string, + termFilters?: FieldValuePair[] +): SearchRequest => { + const query = getQueryWithParams({ params, termFilters }); + + const { index, samplerShardSize } = params; + + const size = 0; + const aggs: Aggs = { + sampled_value_count: { + filter: { exists: { field: fieldName } }, + }, + sampled_values: { + terms: { + field: fieldName, + size: 2, + }, + }, + }; + + const searchBody = { + query, + aggs: { + sample: buildSamplerAggregation(aggs, samplerShardSize), + }, + }; + + return { + index, + size, + body: searchBody, + }; +}; + +export const fetchBooleanFieldStats = async ( + esClient: ElasticsearchClient, + params: FieldStatsCommonRequestParams, + field: FieldValuePair, + termFilters?: FieldValuePair[] +): Promise => { + const request = getBooleanFieldStatsRequest( + params, + field.fieldName, + termFilters + ); + const { body } = await esClient.search(request); + const aggregations = body.aggregations as { + sample: { + sampled_value_count: estypes.AggregationsFiltersBucketItemKeys; + sampled_values: estypes.AggregationsTermsAggregate; + }; + }; + + const stats: BooleanFieldStats = { + fieldName: field.fieldName, + count: aggregations?.sample.sampled_value_count.doc_count ?? 0, + }; + + const valueBuckets: TopValueBucket[] = + aggregations?.sample.sampled_values?.buckets ?? []; + valueBuckets.forEach((bucket) => { + stats[`${bucket.key.toString()}Count`] = bucket.doc_count; + }); + return stats; +}; 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 new file mode 100644 index 0000000000000..deb89ace47c5d --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_field_stats.test.ts @@ -0,0 +1,170 @@ +/* + * 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 { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; +import { getNumericFieldStatsRequest } from './get_numeric_field_stats'; +import { getKeywordFieldStatsRequest } from './get_keyword_field_stats'; +import { getBooleanFieldStatsRequest } from './get_boolean_field_stats'; +import { estypes } from '@elastic/elasticsearch'; +import { ElasticsearchClient } from 'kibana/server'; +import { fetchFieldsStats } from './get_fields_stats'; + +const params = { + index: 'apm-*', + start: '2020', + end: '2021', + includeFrozen: false, + environment: ENVIRONMENT_ALL.value, + kuery: '', + samplerShardSize: 5000, +}; + +export const getExpectedQuery = (aggs: any) => { + return { + body: { + aggs, + query: { + bool: { + filter: [ + { term: { 'processor.event': 'transaction' } }, + { + range: { + '@timestamp': { + format: 'epoch_millis', + gte: 1577836800000, + lte: 1609459200000, + }, + }, + }, + ], + }, + }, + }, + index: 'apm-*', + size: 0, + }; +}; + +describe('field_stats', () => { + describe('getNumericFieldStatsRequest', () => { + it('returns request with filter, percentiles, and top terms aggregations ', () => { + const req = getNumericFieldStatsRequest(params, 'url.path'); + + const expectedAggs = { + sample: { + aggs: { + sampled_field_stats: { + aggs: { actual_stats: { stats: { field: 'url.path' } } }, + filter: { exists: { field: 'url.path' } }, + }, + sampled_percentiles: { + percentiles: { + field: 'url.path', + keyed: false, + percents: [50], + }, + }, + sampled_top: { + terms: { + field: 'url.path', + order: { _count: 'desc' }, + size: 10, + }, + }, + }, + sampler: { shard_size: 5000 }, + }, + }; + expect(req).toEqual(getExpectedQuery(expectedAggs)); + }); + }); + describe('getKeywordFieldStatsRequest', () => { + it('returns request with top terms sampler aggregation ', () => { + const req = getKeywordFieldStatsRequest(params, 'url.path'); + + const expectedAggs = { + sample: { + sampler: { shard_size: 5000 }, + aggs: { + sampled_top: { + terms: { field: 'url.path', size: 10, order: { _count: 'desc' } }, + }, + }, + }, + }; + expect(req).toEqual(getExpectedQuery(expectedAggs)); + }); + }); + describe('getBooleanFieldStatsRequest', () => { + it('returns request with top terms sampler aggregation ', () => { + const req = getBooleanFieldStatsRequest(params, 'url.path'); + + const expectedAggs = { + sample: { + sampler: { shard_size: 5000 }, + aggs: { + sampled_value_count: { + filter: { exists: { field: 'url.path' } }, + }, + sampled_values: { terms: { field: 'url.path', size: 2 } }, + }, + }, + }; + expect(req).toEqual(getExpectedQuery(expectedAggs)); + }); + }); + + describe('fetchFieldsStats', () => { + it('returns field candidates and total hits', async () => { + const fieldsCaps = { + body: { + fields: { + myIpFieldName: { ip: {} }, + myKeywordFieldName: { keyword: {} }, + myMultiFieldName: { keyword: {}, text: {} }, + myHistogramFieldName: { histogram: {} }, + myNumericFieldName: { number: {} }, + }, + }, + }; + const esClientFieldCapsMock = jest.fn(() => fieldsCaps); + + const fieldsToSample = Object.keys(fieldsCaps.body.fields); + + const esClientSearchMock = jest.fn( + ( + req: estypes.SearchRequest + ): { + body: estypes.SearchResponse; + } => { + return { + body: { + aggregations: { sample: {} }, + } as unknown as estypes.SearchResponse, + }; + } + ); + + const esClientMock = { + fieldCaps: esClientFieldCapsMock, + search: esClientSearchMock, + } as unknown as ElasticsearchClient; + + const resp = await fetchFieldsStats(esClientMock, params, fieldsToSample); + // Should not return stats for unsupported field types like histogram + const expectedFields = [ + 'myIpFieldName', + 'myKeywordFieldName', + 'myMultiFieldName', + 'myNumericFieldName', + ]; + expect(resp.stats.map((s) => s.fieldName)).toEqual(expectedFields); + expect(esClientFieldCapsMock).toHaveBeenCalledTimes(1); + expect(esClientSearchMock).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_fields_stats.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_fields_stats.ts new file mode 100644 index 0000000000000..2e1441ccbd6a1 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_fields_stats.ts @@ -0,0 +1,110 @@ +/* + * 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 { ElasticsearchClient } from 'kibana/server'; +import { chunk } from 'lodash'; +import { ES_FIELD_TYPES } from '@kbn/field-types'; +import { + FieldValuePair, + SearchStrategyParams, +} from '../../../../../common/search_strategies/types'; +import { getRequestBase } from '../get_request_base'; +import { fetchKeywordFieldStats } from './get_keyword_field_stats'; +import { fetchNumericFieldStats } from './get_numeric_field_stats'; +import { + FieldStats, + FieldStatsCommonRequestParams, +} from '../../../../../common/search_strategies/field_stats_types'; +import { fetchBooleanFieldStats } from './get_boolean_field_stats'; + +export const fetchFieldsStats = async ( + esClient: ElasticsearchClient, + params: SearchStrategyParams, + fieldsToSample: string[], + termFilters?: FieldValuePair[] +): Promise<{ stats: FieldStats[]; errors: any[] }> => { + const stats: FieldStats[] = []; + const errors: any[] = []; + + if (fieldsToSample.length === 0) return { stats, errors }; + + const respMapping = await esClient.fieldCaps({ + ...getRequestBase(params), + fields: fieldsToSample, + }); + + const fieldStatsParams: FieldStatsCommonRequestParams = { + ...params, + samplerShardSize: 5000, + }; + const fieldStatsPromises = Object.entries(respMapping.body.fields) + .map(([key, value], idx) => { + const field: FieldValuePair = { fieldName: key, fieldValue: '' }; + const fieldTypes = Object.keys(value); + + for (const ft of fieldTypes) { + switch (ft) { + case ES_FIELD_TYPES.KEYWORD: + case ES_FIELD_TYPES.IP: + return fetchKeywordFieldStats( + esClient, + fieldStatsParams, + field, + termFilters + ); + break; + + case 'numeric': + case 'number': + case ES_FIELD_TYPES.FLOAT: + case ES_FIELD_TYPES.HALF_FLOAT: + case ES_FIELD_TYPES.SCALED_FLOAT: + case ES_FIELD_TYPES.DOUBLE: + case ES_FIELD_TYPES.INTEGER: + case ES_FIELD_TYPES.LONG: + case ES_FIELD_TYPES.SHORT: + case ES_FIELD_TYPES.UNSIGNED_LONG: + case ES_FIELD_TYPES.BYTE: + return fetchNumericFieldStats( + esClient, + fieldStatsParams, + field, + termFilters + ); + + break; + case ES_FIELD_TYPES.BOOLEAN: + return fetchBooleanFieldStats( + esClient, + fieldStatsParams, + field, + termFilters + ); + + default: + return; + } + } + }) + .filter((f) => f !== undefined) as Array>; + + const batches = chunk(fieldStatsPromises, 10); + for (let i = 0; i < batches.length; i++) { + try { + const results = await Promise.allSettled(batches[i]); + results.forEach((r) => { + if (r.status === 'fulfilled' && r.value !== undefined) { + stats.push(r.value); + } + }); + } catch (e) { + errors.push(e); + } + } + + return { stats, errors }; +}; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_keyword_field_stats.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_keyword_field_stats.ts new file mode 100644 index 0000000000000..b15449657cba5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_keyword_field_stats.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ElasticsearchClient } from 'kibana/server'; +import { SearchRequest } from '@elastic/elasticsearch/api/types'; +import { estypes } from '@elastic/elasticsearch'; +import { FieldValuePair } from '../../../../../common/search_strategies/types'; +import { getQueryWithParams } from '../get_query_with_params'; +import { buildSamplerAggregation } from '../../utils/field_stats_utils'; +import { + FieldStatsCommonRequestParams, + KeywordFieldStats, + Aggs, + TopValueBucket, +} from '../../../../../common/search_strategies/field_stats_types'; + +export const getKeywordFieldStatsRequest = ( + params: FieldStatsCommonRequestParams, + fieldName: string, + termFilters?: FieldValuePair[] +): SearchRequest => { + const query = getQueryWithParams({ params, termFilters }); + + const { index, samplerShardSize } = params; + + const size = 0; + const aggs: Aggs = { + sampled_top: { + terms: { + field: fieldName, + size: 10, + order: { + _count: 'desc', + }, + }, + }, + }; + + const searchBody = { + query, + aggs: { + sample: buildSamplerAggregation(aggs, samplerShardSize), + }, + }; + + return { + index, + size, + body: searchBody, + }; +}; + +export const fetchKeywordFieldStats = async ( + esClient: ElasticsearchClient, + params: FieldStatsCommonRequestParams, + field: FieldValuePair, + termFilters?: FieldValuePair[] +): Promise => { + const request = getKeywordFieldStatsRequest( + params, + field.fieldName, + termFilters + ); + const { body } = await esClient.search(request); + const aggregations = body.aggregations as { + sample: { + sampled_top: estypes.AggregationsTermsAggregate; + }; + }; + const topValues: TopValueBucket[] = + aggregations?.sample.sampled_top?.buckets ?? []; + + const stats = { + fieldName: field.fieldName, + topValues, + topValuesSampleSize: topValues.reduce( + (acc, curr) => acc + curr.doc_count, + aggregations.sample.sampled_top?.sum_other_doc_count ?? 0 + ), + }; + + return stats; +}; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_numeric_field_stats.ts b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_numeric_field_stats.ts new file mode 100644 index 0000000000000..bab4a1af29b65 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/queries/field_stats/get_numeric_field_stats.ts @@ -0,0 +1,130 @@ +/* + * 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 { ElasticsearchClient } from 'kibana/server'; +import { SearchRequest } from '@elastic/elasticsearch/api/types'; +import { find, get } from 'lodash'; +import { estypes } from '@elastic/elasticsearch/index'; +import { + NumericFieldStats, + FieldStatsCommonRequestParams, + TopValueBucket, + Aggs, +} from '../../../../../common/search_strategies/field_stats_types'; +import { FieldValuePair } from '../../../../../common/search_strategies/types'; +import { getQueryWithParams } from '../get_query_with_params'; +import { buildSamplerAggregation } from '../../utils/field_stats_utils'; + +// Only need 50th percentile for the median +const PERCENTILES = [50]; + +export const getNumericFieldStatsRequest = ( + params: FieldStatsCommonRequestParams, + fieldName: string, + termFilters?: FieldValuePair[] +) => { + const query = getQueryWithParams({ params, termFilters }); + const size = 0; + + const { index, samplerShardSize } = params; + + const percents = PERCENTILES; + const aggs: Aggs = { + sampled_field_stats: { + filter: { exists: { field: fieldName } }, + aggs: { + actual_stats: { + stats: { field: fieldName }, + }, + }, + }, + sampled_percentiles: { + percentiles: { + field: fieldName, + percents, + keyed: false, + }, + }, + sampled_top: { + terms: { + field: fieldName, + size: 10, + order: { + _count: 'desc', + }, + }, + }, + }; + + const searchBody = { + query, + aggs: { + sample: buildSamplerAggregation(aggs, samplerShardSize), + }, + }; + + return { + index, + size, + body: searchBody, + }; +}; + +export const fetchNumericFieldStats = async ( + esClient: ElasticsearchClient, + params: FieldStatsCommonRequestParams, + field: FieldValuePair, + termFilters?: FieldValuePair[] +): Promise => { + const request: SearchRequest = getNumericFieldStatsRequest( + params, + field.fieldName, + termFilters + ); + const { body } = await esClient.search(request); + + const aggregations = body.aggregations as { + sample: { + sampled_top: estypes.AggregationsTermsAggregate; + sampled_percentiles: estypes.AggregationsHdrPercentilesAggregate; + sampled_field_stats: { + doc_count: number; + actual_stats: estypes.AggregationsStatsAggregate; + }; + }; + }; + const docCount = aggregations?.sample.sampled_field_stats?.doc_count ?? 0; + const fieldStatsResp = + aggregations?.sample.sampled_field_stats?.actual_stats ?? {}; + const topValues = aggregations?.sample.sampled_top?.buckets ?? []; + + const stats: NumericFieldStats = { + fieldName: field.fieldName, + count: docCount, + min: get(fieldStatsResp, 'min', 0), + max: get(fieldStatsResp, 'max', 0), + avg: get(fieldStatsResp, 'avg', 0), + topValues, + topValuesSampleSize: topValues.reduce( + (acc: number, curr: TopValueBucket) => acc + curr.doc_count, + aggregations.sample.sampled_top?.sum_other_doc_count ?? 0 + ), + }; + + if (stats.count !== undefined && stats.count > 0) { + const percentiles = aggregations?.sample.sampled_percentiles.values ?? []; + const medianPercentile: { value: number; key: number } | undefined = find( + percentiles, + { + key: 50, + } + ); + stats.median = medianPercentile !== undefined ? medianPercentile!.value : 0; + } + + return stats; +}; diff --git a/x-pack/plugins/apm/server/lib/search_strategies/utils/field_stats_utils.ts b/x-pack/plugins/apm/server/lib/search_strategies/utils/field_stats_utils.ts new file mode 100644 index 0000000000000..2eb67ec501bab --- /dev/null +++ b/x-pack/plugins/apm/server/lib/search_strategies/utils/field_stats_utils.ts @@ -0,0 +1,58 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +/* + * Contains utility functions for building and processing queries. + */ + +// Builds the base filter criteria used in queries, +// adding criteria for the time range and an optional query. +export function buildBaseFilterCriteria( + timeFieldName?: string, + earliestMs?: number, + latestMs?: number, + query?: object +) { + const filterCriteria = []; + if (timeFieldName && earliestMs && latestMs) { + filterCriteria.push({ + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }); + } + + if (query) { + filterCriteria.push(query); + } + + return filterCriteria; +} + +// Wraps the supplied aggregations in a sampler aggregation. +// A supplied samplerShardSize (the shard_size parameter of the sampler aggregation) +// of less than 1 indicates no sampling, and the aggs are returned as-is. +export function buildSamplerAggregation( + aggs: any, + samplerShardSize: number +): estypes.AggregationsAggregationContainer { + if (samplerShardSize < 1) { + return aggs; + } + + return { + sampler: { + shard_size: samplerShardSize, + }, + aggs, + }; +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8da57b473c760..1e538b63e37d6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6366,7 +6366,6 @@ "xpack.apm.clearFilters": "フィルターを消去", "xpack.apm.compositeSpanCallsLabel": "、{count}件の呼び出し、平均{duration}", "xpack.apm.compositeSpanDurationLabel": "平均時間", - "xpack.apm.correlations.correlationsTable.actionsLabel": "フィルター", "xpack.apm.correlations.correlationsTable.excludeDescription": "値を除外", "xpack.apm.correlations.correlationsTable.excludeLabel": "除外", "xpack.apm.correlations.correlationsTable.filterDescription": "値でフィルタリング", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9dadf6ca4ef5d..c0d2970983166 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6417,7 +6417,6 @@ "xpack.apm.clearFilters": "清除筛选", "xpack.apm.compositeSpanCallsLabel": ",{count} 个调用,平均 {duration}", "xpack.apm.compositeSpanDurationLabel": "平均持续时间", - "xpack.apm.correlations.correlationsTable.actionsLabel": "筛选", "xpack.apm.correlations.correlationsTable.excludeDescription": "筛除值", "xpack.apm.correlations.correlationsTable.excludeLabel": "排除", "xpack.apm.correlations.correlationsTable.filterDescription": "按值筛选", 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 337dc65b532ff..6e2025a7fa2ca 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 @@ -217,6 +217,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); expect(finalRawResponse?.errorHistogram.length).to.be(101); expect(finalRawResponse?.overallHistogram.length).to.be(101); + expect(finalRawResponse?.fieldStats.length).to.be(26); expect(finalRawResponse?.failedTransactionsCorrelations.length).to.eql( 30, @@ -227,6 +228,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { 'Fetched 95th percentile value of 1309695.875 based on 1244 documents.', 'Identified 68 fieldCandidates.', 'Identified correlations for 68 fields out of 68 candidates.', + 'Identified 26 fields to sample for field statistics.', + 'Retrieved field statistics for 26 fields out of 26 fields.', 'Identified 30 significant correlations relating to failed transactions.', ]); @@ -243,6 +246,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(typeof correlation?.normalizedScore).to.be('number'); expect(typeof correlation?.failurePercentage).to.be('number'); expect(typeof correlation?.successPercentage).to.be('number'); + + const fieldStats = finalRawResponse?.fieldStats[0]; + expect(typeof fieldStats).to.be('object'); + expect(fieldStats.topValues.length).to.greaterThan(0); + expect(fieldStats.topValuesSampleSize).to.greaterThan(0); }); }); } 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 496d4966efb86..99aee770c625d 100644 --- a/x-pack/test/apm_api_integration/tests/correlations/latency.ts +++ b/x-pack/test/apm_api_integration/tests/correlations/latency.ts @@ -236,6 +236,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(typeof finalRawResponse?.took).to.be('number'); expect(finalRawResponse?.percentileThresholdValue).to.be(1309695.875); expect(finalRawResponse?.overallHistogram.length).to.be(101); + expect(finalRawResponse?.fieldStats.length).to.be(12); expect(finalRawResponse?.latencyCorrelations.length).to.eql( 13, @@ -250,15 +251,23 @@ export default function ApiTest({ getService }: FtrProviderContext) { 'Identified 379 fieldValuePairs.', 'Loaded fractions and totalDocCount of 1244.', 'Identified 13 significant correlations out of 379 field/value pairs.', + 'Identified 12 fields to sample for field statistics.', + 'Retrieved field statistics for 12 fields out of 12 fields.', ]); const correlation = finalRawResponse?.latencyCorrelations[0]; + expect(typeof correlation).to.be('object'); expect(correlation?.fieldName).to.be('transaction.result'); expect(correlation?.fieldValue).to.be('success'); expect(correlation?.correlation).to.be(0.6275246559191225); expect(correlation?.ksTest).to.be(4.806503252860024e-13); expect(correlation?.histogram.length).to.be(101); + + const fieldStats = finalRawResponse?.fieldStats[0]; + expect(typeof fieldStats).to.be('object'); + expect(fieldStats.topValues.length).to.greaterThan(0); + expect(fieldStats.topValuesSampleSize).to.greaterThan(0); }); } ); From 1933ed9791b311c34f120bf2dace154a975e964a Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:01:01 -0400 Subject: [PATCH 12/54] [7.x] Update security deprecation messages (#115241) (#115395) * Update security deprecation messages (#115241) * Fix Jest tests that were changed upstream * Add missing deprecation levels for plugins' `enabled` config options For the Security, Spaces, and ESO plugins. --- .../elasticsearch_config.test.ts | 8 +- .../elasticsearch/elasticsearch_config.ts | 86 +++++++++++++------ .../encrypted_saved_objects/server/index.ts | 2 +- .../monitoring/server/deprecations.test.js | 58 ------------- .../plugins/monitoring/server/deprecations.ts | 57 ++---------- .../server/config_deprecations.test.ts | 40 ++++----- .../security/server/config_deprecations.ts | 76 +++++++++------- x-pack/plugins/spaces/server/config.ts | 1 + 8 files changed, 136 insertions(+), 192 deletions(-) diff --git a/src/core/server/elasticsearch/elasticsearch_config.test.ts b/src/core/server/elasticsearch/elasticsearch_config.test.ts index c273722b5082f..562d25c41caeb 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.test.ts @@ -293,7 +293,7 @@ describe('deprecations', () => { const { messages } = applyElasticsearchDeprecations({ username: 'elastic' }); expect(messages).toMatchInlineSnapshot(` Array [ - "Setting [${CONFIG_PATH}.username] to \\"elastic\\" is deprecated. You should use the \\"kibana_system\\" user instead.", + "Kibana is configured to authenticate to Elasticsearch with the \\"elastic\\" user. Use a service account token instead.", ] `); }); @@ -302,7 +302,7 @@ describe('deprecations', () => { const { messages } = applyElasticsearchDeprecations({ username: 'kibana' }); expect(messages).toMatchInlineSnapshot(` Array [ - "Setting [${CONFIG_PATH}.username] to \\"kibana\\" is deprecated. You should use the \\"kibana_system\\" user instead.", + "Kibana is configured to authenticate to Elasticsearch with the \\"kibana\\" user. Use a service account token instead.", ] `); }); @@ -321,7 +321,7 @@ describe('deprecations', () => { const { messages } = applyElasticsearchDeprecations({ ssl: { key: '' } }); expect(messages).toMatchInlineSnapshot(` Array [ - "Setting [${CONFIG_PATH}.ssl.key] without [${CONFIG_PATH}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + "Use both \\"elasticsearch.ssl.key\\" and \\"elasticsearch.ssl.certificate\\" to enable Kibana to use Mutual TLS authentication with Elasticsearch.", ] `); }); @@ -330,7 +330,7 @@ describe('deprecations', () => { const { messages } = applyElasticsearchDeprecations({ ssl: { certificate: '' } }); expect(messages).toMatchInlineSnapshot(` Array [ - "Setting [${CONFIG_PATH}.ssl.certificate] without [${CONFIG_PATH}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.", + "Use both \\"elasticsearch.ssl.certificate\\" and \\"elasticsearch.ssl.key\\" to enable Kibana to use Mutual TLS authentication with Elasticsearch.", ] `); }); diff --git a/src/core/server/elasticsearch/elasticsearch_config.ts b/src/core/server/elasticsearch/elasticsearch_config.ts index 71b9f87c14fa3..ef5d05c8d2280 100644 --- a/src/core/server/elasticsearch/elasticsearch_config.ts +++ b/src/core/server/elasticsearch/elasticsearch_config.ts @@ -8,6 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { readPkcs12Keystore, readPkcs12Truststore } from '@kbn/crypto'; +import { i18n } from '@kbn/i18n'; import { Duration } from 'moment'; import { readFileSync } from 'fs'; import { ConfigDeprecationProvider } from 'src/core/server'; @@ -138,49 +139,82 @@ export const configSchema = schema.object({ }); const deprecations: ConfigDeprecationProvider = () => [ - (settings, fromPath, addDeprecation) => { + (settings, fromPath, addDeprecation, { branch }) => { const es = settings[fromPath]; if (!es) { return; } - if (es.username === 'elastic') { - addDeprecation({ - configPath: `${fromPath}.username`, - message: `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.`, - correctiveActions: { - manualSteps: [`Replace [${fromPath}.username] from "elastic" to "kibana_system".`], - }, - }); - } else if (es.username === 'kibana') { + + if (es.username === 'elastic' || es.username === 'kibana') { + const username = es.username; addDeprecation({ configPath: `${fromPath}.username`, - message: `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.`, - correctiveActions: { - manualSteps: [`Replace [${fromPath}.username] from "kibana" to "kibana_system".`], - }, - }); - } - if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { - addDeprecation({ - configPath: `${fromPath}.ssl.key`, - message: `Setting [${fromPath}.ssl.key] without [${fromPath}.ssl.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`, + title: i18n.translate('core.deprecations.elasticsearchUsername.title', { + defaultMessage: 'Using "elasticsearch.username: {username}" is deprecated', + values: { username }, + }), + message: i18n.translate('core.deprecations.elasticsearchUsername.message', { + defaultMessage: + 'Kibana is configured to authenticate to Elasticsearch with the "{username}" user. Use a service account token instead.', + values: { username }, + }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${branch}/service-accounts.html`, correctiveActions: { manualSteps: [ - `Set [${fromPath}.ssl.certificate] in your kibana configs to enable TLS client authentication to Elasticsearch.`, + i18n.translate('core.deprecations.elasticsearchUsername.manualSteps1', { + defaultMessage: + 'Use the elasticsearch-service-tokens CLI tool to create a new service account token for the "elastic/kibana" service account.', + }), + i18n.translate('core.deprecations.elasticsearchUsername.manualSteps2', { + defaultMessage: 'Add the "elasticsearch.serviceAccountToken" setting to kibana.yml.', + }), + i18n.translate('core.deprecations.elasticsearchUsername.manualSteps3', { + defaultMessage: + 'Remove "elasticsearch.username" and "elasticsearch.password" from kibana.yml.', + }), ], }, }); - } else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) { + } + + const addSslDeprecation = (existingSetting: string, missingSetting: string) => { addDeprecation({ - configPath: `${fromPath}.ssl.certificate`, - message: `Setting [${fromPath}.ssl.certificate] without [${fromPath}.ssl.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`, + configPath: existingSetting, + title: i18n.translate('core.deprecations.elasticsearchSSL.title', { + defaultMessage: 'Using "{existingSetting}" without "{missingSetting}" has no effect', + values: { existingSetting, missingSetting }, + }), + message: i18n.translate('core.deprecations.elasticsearchSSL.message', { + defaultMessage: + 'Use both "{existingSetting}" and "{missingSetting}" to enable Kibana to use Mutual TLS authentication with Elasticsearch.', + values: { existingSetting, missingSetting }, + }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/${branch}/elasticsearch-mutual-tls.html`, correctiveActions: { manualSteps: [ - `Set [${fromPath}.ssl.key] in your kibana configs to enable TLS client authentication to Elasticsearch.`, + i18n.translate('core.deprecations.elasticsearchSSL.manualSteps1', { + defaultMessage: 'Add the "{missingSetting}" setting to kibana.yml.', + values: { missingSetting }, + }), + i18n.translate('core.deprecations.elasticsearchSSL.manualSteps2', { + defaultMessage: + 'Alternatively, if you don\'t want to use Mutual TLS authentication, remove "{existingSetting}" from kibana.yml.', + values: { existingSetting }, + }), ], }, }); - } else if (es.logQueries === true) { + }; + + if (es.ssl?.key !== undefined && es.ssl?.certificate === undefined) { + addSslDeprecation(`${fromPath}.ssl.key`, `${fromPath}.ssl.certificate`); + } else if (es.ssl?.certificate !== undefined && es.ssl?.key === undefined) { + addSslDeprecation(`${fromPath}.ssl.certificate`, `${fromPath}.ssl.key`); + } + + if (es.logQueries === true) { addDeprecation({ configPath: `${fromPath}.logQueries`, message: `Setting [${fromPath}.logQueries] is deprecated and no longer used. You should set the log level to "debug" for the "elasticsearch.queries" context in "logging.loggers" or use "logging.verbose: true".`, diff --git a/x-pack/plugins/encrypted_saved_objects/server/index.ts b/x-pack/plugins/encrypted_saved_objects/server/index.ts index b765f1fcaf6fa..c2dee723f0d87 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/index.ts @@ -17,7 +17,7 @@ export type { IsMigrationNeededPredicate } from './create_migration'; export const config: PluginConfigDescriptor = { schema: ConfigSchema, - deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0')], + deprecations: ({ deprecate }) => [deprecate('enabled', '8.0.0', { level: 'critical' })], }; export const plugin = (initializerContext: PluginInitializerContext) => new EncryptedSavedObjectsPlugin(initializerContext); diff --git a/x-pack/plugins/monitoring/server/deprecations.test.js b/x-pack/plugins/monitoring/server/deprecations.test.js index fe0c1850968e0..72f7e40fc5239 100644 --- a/x-pack/plugins/monitoring/server/deprecations.test.js +++ b/x-pack/plugins/monitoring/server/deprecations.test.js @@ -68,64 +68,6 @@ describe.skip('monitoring plugin deprecations', function () { }); }); - describe('elasticsearch.username', function () { - it('logs a warning if elasticsearch.username is set to "elastic"', () => { - const settings = { elasticsearch: { username: 'elastic' } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).toHaveBeenCalled(); - }); - - it('logs a warning if elasticsearch.username is set to "kibana"', () => { - const settings = { elasticsearch: { username: 'kibana' } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).toHaveBeenCalled(); - }); - - it('does not log a warning if elasticsearch.username is set to something besides "elastic" or "kibana"', () => { - const settings = { elasticsearch: { username: 'otheruser' } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).not.toHaveBeenCalled(); - }); - - it('does not log a warning if elasticsearch.username is unset', () => { - const settings = { elasticsearch: { username: undefined } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).not.toHaveBeenCalled(); - }); - - it('logs a warning if ssl.key is set and ssl.certificate is not', () => { - const settings = { elasticsearch: { ssl: { key: '' } } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).toHaveBeenCalled(); - }); - - it('logs a warning if ssl.certificate is set and ssl.key is not', () => { - const settings = { elasticsearch: { ssl: { certificate: '' } } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).toHaveBeenCalled(); - }); - - it('does not log a warning if both ssl.key and ssl.certificate are set', () => { - const settings = { elasticsearch: { ssl: { key: '', certificate: '' } } }; - - const addDeprecation = jest.fn(); - transformDeprecations(settings, fromPath, addDeprecation); - expect(addDeprecation).not.toHaveBeenCalled(); - }); - }); - describe('xpack_api_polling_frequency_millis', () => { it('should call rename for this renamed config key', () => { const settings = { xpack_api_polling_frequency_millis: 30000 }; diff --git a/x-pack/plugins/monitoring/server/deprecations.ts b/x-pack/plugins/monitoring/server/deprecations.ts index 9dec7b105f2f6..13f8c28c5a621 100644 --- a/x-pack/plugins/monitoring/server/deprecations.ts +++ b/x-pack/plugins/monitoring/server/deprecations.ts @@ -66,56 +66,13 @@ export const deprecations = ({ } return config; }, - (config, fromPath, addDeprecation) => { - const es: Record = get(config, 'elasticsearch'); - if (es) { - if (es.username === 'elastic') { - addDeprecation({ - configPath: 'elasticsearch.username', - message: `Setting [${fromPath}.username] to "elastic" is deprecated. You should use the "kibana_system" user instead.`, - correctiveActions: { - manualSteps: [`Replace [${fromPath}.username] from "elastic" to "kibana_system".`], - }, - }); - } else if (es.username === 'kibana') { - addDeprecation({ - configPath: 'elasticsearch.username', - message: `Setting [${fromPath}.username] to "kibana" is deprecated. You should use the "kibana_system" user instead.`, - correctiveActions: { - manualSteps: [`Replace [${fromPath}.username] from "kibana" to "kibana_system".`], - }, - }); - } - } - return config; - }, - (config, fromPath, addDeprecation) => { - const ssl: Record = get(config, 'elasticsearch.ssl'); - if (ssl) { - if (ssl.key !== undefined && ssl.certificate === undefined) { - addDeprecation({ - configPath: 'elasticsearch.ssl.key', - message: `Setting [${fromPath}.key] without [${fromPath}.certificate] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`, - correctiveActions: { - manualSteps: [ - `Set [${fromPath}.ssl.certificate] in your kibana configs to enable TLS client authentication to Elasticsearch.`, - ], - }, - }); - } else if (ssl.certificate !== undefined && ssl.key === undefined) { - addDeprecation({ - configPath: 'elasticsearch.ssl.certificate', - message: `Setting [${fromPath}.certificate] without [${fromPath}.key] is deprecated. This has no effect, you should use both settings to enable TLS client authentication to Elasticsearch.`, - correctiveActions: { - manualSteps: [ - `Set [${fromPath}.ssl.key] in your kibana configs to enable TLS client authentication to Elasticsearch.`, - ], - }, - }); - } - } - return config; - }, rename('xpack_api_polling_frequency_millis', 'licensing.api_polling_frequency'), + + // TODO: Add deprecations for "monitoring.ui.elasticsearch.username: elastic" and "monitoring.ui.elasticsearch.username: kibana". + // TODO: Add deprecations for using "monitoring.ui.elasticsearch.ssl.certificate" without "monitoring.ui.elasticsearch.ssl.key", and + // vice versa. + // ^ These deprecations should only be shown if they are explicitly configured for monitoring -- we should not show Monitoring + // deprecations for these settings if they are inherited from the Core elasticsearch settings. + // See the Core implementation: src/core/server/elasticsearch/elasticsearch_config.ts ]; }; diff --git a/x-pack/plugins/security/server/config_deprecations.test.ts b/x-pack/plugins/security/server/config_deprecations.test.ts index 028200d40164c..0989008c9f932 100644 --- a/x-pack/plugins/security/server/config_deprecations.test.ts +++ b/x-pack/plugins/security/server/config_deprecations.test.ts @@ -106,7 +106,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.sessionTimeout\\" has been replaced by \\"xpack.security.session.idleTimeout\\"", }, Object { @@ -136,7 +136,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.kind\\" has been replaced by \\"xpack.security.audit.appender.type\\"", }, ] @@ -162,7 +162,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.layout.kind\\" has been replaced by \\"xpack.security.audit.appender.layout.type\\"", }, ] @@ -188,7 +188,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.policy.kind\\" has been replaced by \\"xpack.security.audit.appender.policy.type\\"", }, ] @@ -214,7 +214,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.strategy.kind\\" has been replaced by \\"xpack.security.audit.appender.strategy.type\\"", }, ] @@ -241,7 +241,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.path\\" has been replaced by \\"xpack.security.audit.appender.fileName\\"", }, ] @@ -265,7 +265,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"security.showInsecureClusterWarning\\" has been replaced by \\"xpack.security.showInsecureClusterWarning\\"", }, ] @@ -385,7 +385,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "Setting \\"xpack.security.audit.appender.path\\" has been replaced by \\"xpack.security.audit.appender.fileName\\"", }, ] @@ -409,7 +409,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "You no longer need to configure \\"xpack.security.authorization.legacyFallback.enabled\\".", }, ] @@ -433,7 +433,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "warning", "message": "You no longer need to configure \\"xpack.security.authc.saml.maxRedirectURLSize\\".", }, ] @@ -461,8 +461,8 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, - "message": "\\"xpack.security.authc.providers.saml..maxRedirectURLSize\\" is no longer used.", + "level": "warning", + "message": "This setting is no longer used.", }, ] `); @@ -486,8 +486,8 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, - "message": "\\"xpack.security.authc.providers\\" accepts an extended \\"object\\" format instead of an array of provider types.", + "level": "warning", + "message": "Use the new object format instead of an array of provider types.", }, ] `); @@ -509,12 +509,12 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, - "message": "\\"xpack.security.authc.providers\\" accepts an extended \\"object\\" format instead of an array of provider types.", + "level": "warning", + "message": "Use the new object format instead of an array of provider types.", }, Object { - "level": undefined, - "message": "Enabling both \\"basic\\" and \\"token\\" authentication providers in \\"xpack.security.authc.providers\\" is deprecated. Login page will only use \\"token\\" provider.", + "level": "warning", + "message": "Use only one of these providers. When both providers are set, Kibana only uses the \\"token\\" provider.", }, ] `); @@ -534,7 +534,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "critical", "message": "Enabling or disabling the Security plugin in Kibana is deprecated. Configure security in Elasticsearch instead.", }, ] @@ -555,7 +555,7 @@ describe('Config Deprecations', () => { expect(deprecations).toMatchInlineSnapshot(` Array [ Object { - "level": undefined, + "level": "critical", "message": "Enabling or disabling the Security plugin in Kibana is deprecated. Configure security in Elasticsearch instead.", }, ] diff --git a/x-pack/plugins/security/server/config_deprecations.ts b/x-pack/plugins/security/server/config_deprecations.ts index e080fc1217e94..ea9377f6a74a5 100644 --- a/x-pack/plugins/security/server/config_deprecations.ts +++ b/x-pack/plugins/security/server/config_deprecations.ts @@ -13,22 +13,23 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ renameFromRoot, unused, }) => [ - rename('sessionTimeout', 'session.idleTimeout'), - rename('authProviders', 'authc.providers'), + rename('sessionTimeout', 'session.idleTimeout', { level: 'warning' }), + rename('authProviders', 'authc.providers', { level: 'warning' }), - rename('audit.appender.kind', 'audit.appender.type'), - rename('audit.appender.layout.kind', 'audit.appender.layout.type'), - rename('audit.appender.policy.kind', 'audit.appender.policy.type'), - rename('audit.appender.strategy.kind', 'audit.appender.strategy.type'), - rename('audit.appender.path', 'audit.appender.fileName'), + rename('audit.appender.kind', 'audit.appender.type', { level: 'warning' }), + rename('audit.appender.layout.kind', 'audit.appender.layout.type', { level: 'warning' }), + rename('audit.appender.policy.kind', 'audit.appender.policy.type', { level: 'warning' }), + rename('audit.appender.strategy.kind', 'audit.appender.strategy.type', { level: 'warning' }), + rename('audit.appender.path', 'audit.appender.fileName', { level: 'warning' }), renameFromRoot( 'security.showInsecureClusterWarning', - 'xpack.security.showInsecureClusterWarning' + 'xpack.security.showInsecureClusterWarning', + { level: 'warning' } ), - unused('authorization.legacyFallback.enabled'), - unused('authc.saml.maxRedirectURLSize'), + unused('authorization.legacyFallback.enabled', { level: 'warning' }), + unused('authc.saml.maxRedirectURLSize', { level: 'warning' }), // Deprecation warning for the legacy audit logger. (settings, fromPath, addDeprecation, { branch }) => { const auditLoggingEnabled = settings?.xpack?.security?.audit?.enabled ?? false; @@ -97,30 +98,33 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ }, // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. - (settings, fromPath, addDeprecation) => { + (settings, _fromPath, addDeprecation, { branch }) => { if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { addDeprecation({ configPath: 'xpack.security.authc.providers', title: i18n.translate('xpack.security.deprecations.authcProvidersTitle', { - defaultMessage: - 'Defining "xpack.security.authc.providers" as an array of provider types is deprecated', + defaultMessage: 'The array format for "xpack.security.authc.providers" is deprecated', }), message: i18n.translate('xpack.security.deprecations.authcProvidersMessage', { - defaultMessage: - '"xpack.security.authc.providers" accepts an extended "object" format instead of an array of provider types.', + defaultMessage: 'Use the new object format instead of an array of provider types.', }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/${branch}/security-settings-kb.html#authentication-security-settings`, correctiveActions: { manualSteps: [ - i18n.translate('xpack.security.deprecations.authcProviders.manualStepOneMessage', { + i18n.translate('xpack.security.deprecations.authcProviders.manualSteps1', { defaultMessage: - 'Use the extended object format for "xpack.security.authc.providers" in your Kibana configuration.', + 'Remove the "xpack.security.authc.providers" setting from kibana.yml.', + }), + i18n.translate('xpack.security.deprecations.authcProviders.manualSteps2', { + defaultMessage: 'Add your authentication providers using the new object format.', }), ], }, }); } }, - (settings, fromPath, addDeprecation) => { + (settings, _fromPath, addDeprecation, { branch }) => { const hasProviderType = (providerType: string) => { const providers = settings?.xpack?.security?.authc?.providers; if (Array.isArray(providers)) { @@ -133,31 +137,35 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ }; if (hasProviderType('basic') && hasProviderType('token')) { + const basicProvider = 'basic'; + const tokenProvider = 'token'; addDeprecation({ configPath: 'xpack.security.authc.providers', title: i18n.translate('xpack.security.deprecations.basicAndTokenProvidersTitle', { defaultMessage: - 'Both "basic" and "token" authentication providers are enabled in "xpack.security.authc.providers"', + 'Using both "{basicProvider}" and "{tokenProvider}" providers in "xpack.security.authc.providers" has no effect', + values: { basicProvider, tokenProvider }, }), message: i18n.translate('xpack.security.deprecations.basicAndTokenProvidersMessage', { defaultMessage: - 'Enabling both "basic" and "token" authentication providers in "xpack.security.authc.providers" is deprecated. Login page will only use "token" provider.', + 'Use only one of these providers. When both providers are set, Kibana only uses the "{tokenProvider}" provider.', + values: { tokenProvider }, }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/${branch}/security-settings-kb.html#authentication-security-settings`, correctiveActions: { manualSteps: [ - i18n.translate( - 'xpack.security.deprecations.basicAndTokenProviders.manualStepOneMessage', - { - defaultMessage: - 'Remove either the "basic" or "token" auth provider in "xpack.security.authc.providers" from your Kibana configuration.', - } - ), + i18n.translate('xpack.security.deprecations.basicAndTokenProviders.manualSteps1', { + defaultMessage: + 'Remove the "{basicProvider}" provider from "xpack.security.authc.providers" in kibana.yml.', + values: { basicProvider }, + }), ], }, }); } }, - (settings, fromPath, addDeprecation) => { + (settings, _fromPath, addDeprecation, { branch }) => { const samlProviders = (settings?.xpack?.security?.authc?.providers?.saml ?? {}) as Record< string, any @@ -171,17 +179,18 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ configPath: `xpack.security.authc.providers.saml.${foundProvider[0]}.maxRedirectURLSize`, title: i18n.translate('xpack.security.deprecations.maxRedirectURLSizeTitle', { defaultMessage: - '"xpack.security.authc.providers.saml..maxRedirectURLSize" is deprecated', + '"xpack.security.authc.providers.saml..maxRedirectURLSize" has no effect', }), message: i18n.translate('xpack.security.deprecations.maxRedirectURLSizeMessage', { - defaultMessage: - '"xpack.security.authc.providers.saml..maxRedirectURLSize" is no longer used.', + defaultMessage: 'This setting is no longer used.', }), + level: 'warning', + documentationUrl: `https://www.elastic.co/guide/en/kibana/${branch}/security-settings-kb.html#authentication-security-settings`, correctiveActions: { manualSteps: [ - i18n.translate('xpack.security.deprecations.maxRedirectURLSize.manualStepOneMessage', { + i18n.translate('xpack.security.deprecations.maxRedirectURLSize.manualSteps1', { defaultMessage: - 'Remove "xpack.security.authc.providers.saml..maxRedirectURLSize" from your Kibana configuration.', + 'Remove "xpack.security.authc.providers.saml..maxRedirectURLSize" from kibana.yml.', }), ], }, @@ -199,6 +208,7 @@ export const securityConfigDeprecationProvider: ConfigDeprecationProvider = ({ defaultMessage: 'Enabling or disabling the Security plugin in Kibana is deprecated. Configure security in Elasticsearch instead.', }), + level: 'critical', documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${branch}/secure-cluster.html`, correctiveActions: { manualSteps: [ diff --git a/x-pack/plugins/spaces/server/config.ts b/x-pack/plugins/spaces/server/config.ts index a8c3a1c6223da..95e4a69e56f65 100644 --- a/x-pack/plugins/spaces/server/config.ts +++ b/x-pack/plugins/spaces/server/config.ts @@ -36,6 +36,7 @@ const disabledDeprecation: ConfigDeprecation = (config, fromPath, addDeprecation defaultMessage: 'This setting will be removed in 8.0 and the Spaces plugin will always be enabled.', }), + level: 'critical', correctiveActions: { manualSteps: [ i18n.translate('xpack.spaces.deprecations.enabled.manualStepOneMessage', { From 2d3a0e8589eb58cdaea58fdf909d44b6a88cf325 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:06:58 -0400 Subject: [PATCH 13/54] center spinner in NoData component (#115210) (#115400) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Kevin Lacabane --- .../public/components/no_data/checking_settings.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/monitoring/public/components/no_data/checking_settings.js b/x-pack/plugins/monitoring/public/components/no_data/checking_settings.js index 86a7537c2e661..d55f2587950af 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/checking_settings.js +++ b/x-pack/plugins/monitoring/public/components/no_data/checking_settings.js @@ -15,18 +15,18 @@ export function CheckingSettings({ checkMessage }) { const message = checkMessage || ( ); return ( - + - {message}... + {message} ); From a453fbb321fc2d82a4c89b11796df59b567659cc Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 18 Oct 2021 20:12:49 +0200 Subject: [PATCH 14/54] Register `kibana_dashboard_only_user` and `kibana_user` roles deprecations in UA. (#110960) --- x-pack/plugins/security/common/index.ts | 2 +- .../security/server/deprecations/index.ts | 13 +- .../kibana_dashboard_only_role.test.ts | 322 +++++++++++++++++ .../kibana_dashboard_only_role.ts | 247 +++++++++++++ .../deprecations/kibana_user_role.test.ts | 328 ++++++++++++++++++ .../server/deprecations/kibana_user_role.ts | 238 +++++++++++++ x-pack/plugins/security/server/plugin.ts | 27 +- .../server/routes/deprecations/index.ts | 13 + .../deprecations/kibana_user_role.test.ts | 283 +++++++++++++++ .../routes/deprecations/kibana_user_role.ts | 145 ++++++++ .../plugins/security/server/routes/index.ts | 4 +- 11 files changed, 1613 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.test.ts create mode 100644 x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.ts create mode 100644 x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts create mode 100644 x-pack/plugins/security/server/deprecations/kibana_user_role.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/index.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index ac5d252c98a8b..1d05036191635 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -6,4 +6,4 @@ */ export type { SecurityLicense } from './licensing'; -export type { AuthenticatedUser } from './model'; +export type { AuthenticatedUser, PrivilegeDeprecationsService } from './model'; diff --git a/x-pack/plugins/security/server/deprecations/index.ts b/x-pack/plugins/security/server/deprecations/index.ts index 05802a5a673c5..0bbeccf1588b4 100644 --- a/x-pack/plugins/security/server/deprecations/index.ts +++ b/x-pack/plugins/security/server/deprecations/index.ts @@ -5,8 +5,13 @@ * 2.0. */ -/** - * getKibanaRolesByFeature - */ - export { getPrivilegeDeprecationsService } from './privilege_deprecations'; +export { + registerKibanaDashboardOnlyRoleDeprecation, + KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME, +} from './kibana_dashboard_only_role'; +export { + registerKibanaUserRoleDeprecation, + KIBANA_ADMIN_ROLE_NAME, + KIBANA_USER_ROLE_NAME, +} from './kibana_user_role'; diff --git a/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.test.ts b/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.test.ts new file mode 100644 index 0000000000000..0ec55ef2f5923 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.test.ts @@ -0,0 +1,322 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import type { SecurityRoleMapping, SecurityUser } from '@elastic/elasticsearch/api/types'; + +import type { PackageInfo, RegisterDeprecationsConfig } from 'src/core/server'; +import { + deprecationsServiceMock, + elasticsearchServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; + +import { licenseMock } from '../../common/licensing/index.mock'; +import { securityMock } from '../mocks'; +import { registerKibanaDashboardOnlyRoleDeprecation } from './kibana_dashboard_only_role'; + +function getDepsMock() { + return { + logger: loggingSystemMock.createLogger(), + deprecationsService: deprecationsServiceMock.createSetupContract(), + license: licenseMock.create(), + packageInfo: { + branch: 'some-branch', + buildSha: 'sha', + dist: true, + version: '8.0.0', + buildNum: 1, + } as PackageInfo, + }; +} + +function getContextMock() { + return { + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: savedObjectsClientMock.create(), + }; +} + +function createMockUser(user: Partial = {}) { + return { enabled: true, username: 'userA', roles: ['roleA'], metadata: {}, ...user }; +} + +function createMockRoleMapping(mapping: Partial = {}) { + return { enabled: true, roles: ['roleA'], rules: {}, metadata: {}, ...mapping }; +} + +describe('Kibana Dashboard Only User role deprecations', () => { + let mockDeps: ReturnType; + let mockContext: ReturnType; + let deprecationHandler: RegisterDeprecationsConfig; + beforeEach(() => { + mockContext = getContextMock(); + mockDeps = getDepsMock(); + registerKibanaDashboardOnlyRoleDeprecation(mockDeps); + + expect(mockDeps.deprecationsService.registerDeprecations).toHaveBeenCalledTimes(1); + deprecationHandler = mockDeps.deprecationsService.registerDeprecations.mock.calls[0][0]; + }); + + it('does not return any deprecations if security is not enabled', async () => { + mockDeps.license.isEnabled.mockReturnValue(false); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + expect(mockContext.esClient.asCurrentUser.security.getUser).not.toHaveBeenCalled(); + expect(mockContext.esClient.asCurrentUser.security.getRoleMapping).not.toHaveBeenCalled(); + }); + + it('does not return any deprecations if none of the users and role mappings has a dashboard only role', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: { enabled: true, roles: ['roleA'], rules: {}, metadata: {} }, + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + }); + + it('returns deprecations even if cannot retrieve users due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve users due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only user-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ + username: 'userB', + roles: ['roleB', 'kibana_dashboard_only_user'], + }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_dashboard_only_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Create a custom role with Kibana privileges to grant access to Dashboard only.", + "Remove the \\"kibana_dashboard_only_user\\" role from all users and add the custom role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Users with the \\"kibana_dashboard_only_user\\" role will not be able to access the Dashboard app. Use Kibana privileges instead.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_dashboard_only_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_dashboard_only_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Create a custom role with Kibana privileges to grant access to Dashboard only.", + "Remove the \\"kibana_dashboard_only_user\\" role from all role mappings and add the custom role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Users with the \\"kibana_dashboard_only_user\\" role will not be able to access the Dashboard app. Use Kibana privileges instead.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns both user-related and role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ + username: 'userB', + roles: ['roleB', 'kibana_dashboard_only_user'], + }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_dashboard_only_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_dashboard_only_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_dashboard_only_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Create a custom role with Kibana privileges to grant access to Dashboard only.", + "Remove the \\"kibana_dashboard_only_user\\" role from all users and add the custom role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Users with the \\"kibana_dashboard_only_user\\" role will not be able to access the Dashboard app. Use Kibana privileges instead.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Create a custom role with Kibana privileges to grant access to Dashboard only.", + "Remove the \\"kibana_dashboard_only_user\\" role from all role mappings and add the custom role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Users with the \\"kibana_dashboard_only_user\\" role will not be able to access the Dashboard app. Use Kibana privileges instead.", + "title": "The \\"kibana_dashboard_only_user\\" role is deprecated", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.ts b/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.ts new file mode 100644 index 0000000000000..4e911b16166f5 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_dashboard_only_role.ts @@ -0,0 +1,247 @@ +/* + * 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 { + SecurityGetRoleMappingResponse, + SecurityGetUserResponse, +} from '@elastic/elasticsearch/api/types'; + +import { i18n } from '@kbn/i18n'; +import type { + DeprecationsDetails, + DeprecationsServiceSetup, + ElasticsearchClient, + Logger, + PackageInfo, +} from 'src/core/server'; + +import type { SecurityLicense } from '../../common'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export const KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME = 'kibana_dashboard_only_user'; + +export interface Deps { + deprecationsService: DeprecationsServiceSetup; + license: SecurityLicense; + logger: Logger; + packageInfo: PackageInfo; +} + +function getDeprecationTitle() { + return i18n.translate('xpack.security.deprecations.kibanaDashboardOnlyUser.deprecationTitle', { + defaultMessage: 'The "{roleName}" role is deprecated', + values: { roleName: KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME }, + }); +} + +function getDeprecationMessage() { + return i18n.translate('xpack.security.deprecations.kibanaDashboardOnlyUser.deprecationMessage', { + defaultMessage: + 'Users with the "{roleName}" role will not be able to access the Dashboard app. Use Kibana privileges instead.', + values: { roleName: KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME }, + }); +} + +export const registerKibanaDashboardOnlyRoleDeprecation = ({ + deprecationsService, + logger, + license, + packageInfo, +}: Deps) => { + deprecationsService.registerDeprecations({ + getDeprecations: async (context) => { + // Nothing to do if security is disabled + if (!license.isEnabled()) { + return []; + } + + return [ + ...(await getUsersDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ...(await getRoleMappingsDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ]; + }, + }); +}; + +async function getUsersDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let users: SecurityGetUserResponse; + try { + users = (await client.security.getUser()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve users when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve users when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const usersWithKibanaDashboardOnlyRole = Object.values(users) + .filter((user) => user.roles.includes(KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME)) + .map((user) => user.username); + if (usersWithKibanaDashboardOnlyRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.usersDeprecationCorrectiveActionOne', + { + defaultMessage: + 'Create a custom role with Kibana privileges to grant access to Dashboard only.', + } + ), + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.usersDeprecationCorrectiveActionTwo', + { + defaultMessage: + 'Remove the "{roleName}" role from all users and add the custom role. The affected users are: {users}.', + values: { + roleName: KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME, + users: usersWithKibanaDashboardOnlyRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +async function getRoleMappingsDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let roleMappings: SecurityGetRoleMappingResponse; + try { + roleMappings = (await client.security.getRoleMapping()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve role mappings when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve role mappings when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const roleMappingsWithKibanaDashboardOnlyRole = Object.entries(roleMappings) + .filter(([, roleMapping]) => roleMapping.roles.includes(KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME)) + .map(([mappingName]) => mappingName); + if (roleMappingsWithKibanaDashboardOnlyRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.roleMappingsDeprecationCorrectiveActionOne', + { + defaultMessage: + 'Create a custom role with Kibana privileges to grant access to Dashboard only.', + } + ), + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.roleMappingsDeprecationCorrectiveActionTwo', + { + defaultMessage: + 'Remove the "{roleName}" role from all role mappings and add the custom role. The affected role mappings are: {roleMappings}.', + values: { + roleName: KIBANA_DASHBOARD_ONLY_USER_ROLE_NAME, + roleMappings: roleMappingsWithKibanaDashboardOnlyRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +function deprecationError(packageInfo: PackageInfo, error: Error): DeprecationsDetails[] { + const title = getDeprecationTitle(); + + if (getErrorStatusCode(error) === 403) { + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.forbiddenErrorMessage', + { defaultMessage: 'You do not have enough permissions to fix this deprecation.' } + ), + documentationUrl: `https://www.elastic.co/guide/en/kibana/${packageInfo.branch}/xpack-security.html#_required_permissions_7`, + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.forbiddenErrorCorrectiveAction', + { + defaultMessage: + 'Make sure you have a "manage_security" cluster privilege assigned.', + } + ), + ], + }, + }, + ]; + } + + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.unknownErrorMessage', + { + defaultMessage: + 'Failed to perform deprecation check. Check Kibana logs for more details.', + } + ), + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaDashboardOnlyUser.unknownErrorCorrectiveAction', + { defaultMessage: 'Check Kibana logs for more details.' } + ), + ], + }, + }, + ]; +} diff --git a/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts b/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts new file mode 100644 index 0000000000000..da728b12fca91 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts @@ -0,0 +1,328 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import type { SecurityRoleMapping, SecurityUser } from '@elastic/elasticsearch/api/types'; + +import type { PackageInfo, RegisterDeprecationsConfig } from 'src/core/server'; +import { + deprecationsServiceMock, + elasticsearchServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; + +import { licenseMock } from '../../common/licensing/index.mock'; +import { securityMock } from '../mocks'; +import { registerKibanaUserRoleDeprecation } from './kibana_user_role'; + +function getDepsMock() { + return { + logger: loggingSystemMock.createLogger(), + deprecationsService: deprecationsServiceMock.createSetupContract(), + license: licenseMock.create(), + packageInfo: { + branch: 'some-branch', + buildSha: 'sha', + dist: true, + version: '8.0.0', + buildNum: 1, + } as PackageInfo, + }; +} + +function getContextMock() { + return { + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: savedObjectsClientMock.create(), + }; +} + +function createMockUser(user: Partial = {}) { + return { enabled: true, username: 'userA', roles: ['roleA'], metadata: {}, ...user }; +} + +function createMockRoleMapping(mapping: Partial = {}) { + return { enabled: true, roles: ['roleA'], rules: {}, metadata: {}, ...mapping }; +} + +describe('Kibana Dashboard Only User role deprecations', () => { + let mockDeps: ReturnType; + let mockContext: ReturnType; + let deprecationHandler: RegisterDeprecationsConfig; + beforeEach(() => { + mockContext = getContextMock(); + mockDeps = getDepsMock(); + registerKibanaUserRoleDeprecation(mockDeps); + + expect(mockDeps.deprecationsService.registerDeprecations).toHaveBeenCalledTimes(1); + deprecationHandler = mockDeps.deprecationsService.registerDeprecations.mock.calls[0][0]; + }); + + it('does not return any deprecations if security is not enabled', async () => { + mockDeps.license.isEnabled.mockReturnValue(false); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + expect(mockContext.esClient.asCurrentUser.security.getUser).not.toHaveBeenCalled(); + expect(mockContext.esClient.asCurrentUser.security.getRoleMapping).not.toHaveBeenCalled(); + }); + + it('does not return any deprecations if none of the users and role mappings has a kibana user role', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: { enabled: true, roles: ['roleA'], rules: {}, metadata: {} }, + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + }); + + it('returns deprecations even if cannot retrieve users due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve users due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only user-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_users", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all users and add the \\"kibana_admin\\" role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_role_mappings", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all role mappings and add the \\"kibana_admin\\" role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns both user-related and role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_users", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all users and add the \\"kibana_admin\\" role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_role_mappings", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all role mappings and add the \\"kibana_admin\\" role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/security/server/deprecations/kibana_user_role.ts b/x-pack/plugins/security/server/deprecations/kibana_user_role.ts new file mode 100644 index 0000000000000..d659ea273f05f --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_user_role.ts @@ -0,0 +1,238 @@ +/* + * 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 { + SecurityGetRoleMappingResponse, + SecurityGetUserResponse, +} from '@elastic/elasticsearch/api/types'; + +import { i18n } from '@kbn/i18n'; +import type { + DeprecationsDetails, + DeprecationsServiceSetup, + ElasticsearchClient, + Logger, + PackageInfo, +} from 'src/core/server'; + +import type { SecurityLicense } from '../../common'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export const KIBANA_USER_ROLE_NAME = 'kibana_user'; +export const KIBANA_ADMIN_ROLE_NAME = 'kibana_admin'; + +interface Deps { + deprecationsService: DeprecationsServiceSetup; + license: SecurityLicense; + logger: Logger; + packageInfo: PackageInfo; +} + +function getDeprecationTitle() { + return i18n.translate('xpack.security.deprecations.kibanaUser.deprecationTitle', { + defaultMessage: 'The "{userRoleName}" role is deprecated', + values: { userRoleName: KIBANA_USER_ROLE_NAME }, + }); +} + +function getDeprecationMessage() { + return i18n.translate('xpack.security.deprecations.kibanaUser.deprecationMessage', { + defaultMessage: + 'Use the "{adminRoleName}" role to grant access to all Kibana features in all spaces.', + values: { adminRoleName: KIBANA_ADMIN_ROLE_NAME }, + }); +} + +export const registerKibanaUserRoleDeprecation = ({ + deprecationsService, + logger, + license, + packageInfo, +}: Deps) => { + deprecationsService.registerDeprecations({ + getDeprecations: async (context) => { + // Nothing to do if security is disabled + if (!license.isEnabled()) { + return []; + } + + return [ + ...(await getUsersDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ...(await getRoleMappingsDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ]; + }, + }); +}; + +async function getUsersDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let users: SecurityGetUserResponse; + try { + users = (await client.security.getUser()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve users when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve users when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const usersWithKibanaUserRole = Object.values(users) + .filter((user) => user.roles.includes(KIBANA_USER_ROLE_NAME)) + .map((user) => user.username); + if (usersWithKibanaUserRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + api: { + method: 'POST', + path: '/internal/security/deprecations/kibana_user_role/_fix_users', + }, + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.usersDeprecationCorrectiveAction', + { + defaultMessage: + 'Remove the "{userRoleName}" role from all users and add the "{adminRoleName}" role. The affected users are: {users}.', + values: { + userRoleName: KIBANA_USER_ROLE_NAME, + adminRoleName: KIBANA_ADMIN_ROLE_NAME, + users: usersWithKibanaUserRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +async function getRoleMappingsDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let roleMappings: SecurityGetRoleMappingResponse; + try { + roleMappings = (await client.security.getRoleMapping()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve role mappings when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve role mappings when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const roleMappingsWithKibanaUserRole = Object.entries(roleMappings) + .filter(([, roleMapping]) => roleMapping.roles.includes(KIBANA_USER_ROLE_NAME)) + .map(([mappingName]) => mappingName); + if (roleMappingsWithKibanaUserRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + api: { + method: 'POST', + path: '/internal/security/deprecations/kibana_user_role/_fix_role_mappings', + }, + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.roleMappingsDeprecationCorrectiveAction', + { + defaultMessage: + 'Remove the "{userRoleName}" role from all role mappings and add the "{adminRoleName}" role. The affected role mappings are: {roleMappings}.', + values: { + userRoleName: KIBANA_USER_ROLE_NAME, + adminRoleName: KIBANA_ADMIN_ROLE_NAME, + roleMappings: roleMappingsWithKibanaUserRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +function deprecationError(packageInfo: PackageInfo, error: Error): DeprecationsDetails[] { + const title = getDeprecationTitle(); + + if (getErrorStatusCode(error) === 403) { + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate('xpack.security.deprecations.kibanaUser.forbiddenErrorMessage', { + defaultMessage: 'You do not have enough permissions to fix this deprecation.', + }), + documentationUrl: `https://www.elastic.co/guide/en/kibana/${packageInfo.branch}/xpack-security.html#_required_permissions_7`, + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.forbiddenErrorCorrectiveAction', + { + defaultMessage: + 'Make sure you have a "manage_security" cluster privilege assigned.', + } + ), + ], + }, + }, + ]; + } + + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate('xpack.security.deprecations.kibanaUser.unknownErrorMessage', { + defaultMessage: 'Failed to perform deprecation check. Check Kibana logs for more details.', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.security.deprecations.kibanaUser.unknownErrorCorrectiveAction', { + defaultMessage: 'Check Kibana logs for more details.', + }), + ], + }, + }, + ]; +} diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 98e77038f168a..bf540d5d4ddc8 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -27,9 +27,8 @@ import type { import type { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import type { SecurityLicense } from '../common/licensing'; +import type { AuthenticatedUser, PrivilegeDeprecationsService, SecurityLicense } from '../common'; import { SecurityLicenseService } from '../common/licensing'; -import type { AuthenticatedUser, PrivilegeDeprecationsService } from '../common/model'; import type { AnonymousAccessServiceStart } from './anonymous_access'; import { AnonymousAccessService } from './anonymous_access'; import type { AuditServiceSetup } from './audit'; @@ -43,7 +42,11 @@ import type { AuthorizationServiceSetup, AuthorizationServiceSetupInternal } fro import { AuthorizationService } from './authorization'; import type { ConfigSchema, ConfigType } from './config'; import { createConfig } from './config'; -import { getPrivilegeDeprecationsService } from './deprecations'; +import { + getPrivilegeDeprecationsService, + registerKibanaDashboardOnlyRoleDeprecation, + registerKibanaUserRoleDeprecation, +} from './deprecations'; import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; @@ -290,6 +293,8 @@ export class SecurityPlugin getSpacesService: () => spaces?.spacesService, }); + this.registerDeprecations(core, license); + defineRoutes({ router: core.http.createRouter(), basePath: core.http.basePath, @@ -414,4 +419,20 @@ export class SecurityPlugin this.authorizationService.stop(); this.sessionManagementService.stop(); } + + private registerDeprecations(core: CoreSetup, license: SecurityLicense) { + const logger = this.logger.get('deprecations'); + registerKibanaDashboardOnlyRoleDeprecation({ + deprecationsService: core.deprecations, + license, + logger, + packageInfo: this.initializerContext.env.packageInfo, + }); + registerKibanaUserRoleDeprecation({ + deprecationsService: core.deprecations, + license, + logger, + packageInfo: this.initializerContext.env.packageInfo, + }); + } } diff --git a/x-pack/plugins/security/server/routes/deprecations/index.ts b/x-pack/plugins/security/server/routes/deprecations/index.ts new file mode 100644 index 0000000000000..cbc186ed2e925 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { RouteDefinitionParams } from '../'; +import { defineKibanaUserRoleDeprecationRoutes } from './kibana_user_role'; + +export function defineDeprecationsRoutes(params: RouteDefinitionParams) { + defineKibanaUserRoleDeprecationRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts new file mode 100644 index 0000000000000..b2ae2543bd652 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts @@ -0,0 +1,283 @@ +/* + * 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 { errors } from '@elastic/elasticsearch'; +import type { SecurityRoleMapping, SecurityUser } from '@elastic/elasticsearch/api/types'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { RequestHandler, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; + +import { securityMock } from '../../mocks'; +import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineKibanaUserRoleDeprecationRoutes } from './kibana_user_role'; + +function createMockUser(user: Partial = {}) { + return { enabled: true, username: 'userA', roles: ['roleA'], metadata: {}, ...user }; +} + +function createMockRoleMapping(mapping: Partial = {}) { + return { enabled: true, roles: ['roleA'], rules: {}, metadata: {}, ...mapping }; +} + +describe('Kibana user deprecation routes', () => { + let router: jest.Mocked; + let mockContext: DeeplyMockedKeys; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + mockContext = { + core: coreMock.createRequestHandlerContext(), + licensing: { license: { check: jest.fn().mockReturnValue({ state: 'valid' }) } }, + } as any; + + defineKibanaUserRoleDeprecationRoutes(routeParamsMock); + }); + + describe('Users with Kibana user role', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [fixUsersRouteConfig, fixUsersRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/deprecations/kibana_user_role/_fix_users' + )!; + + routeConfig = fixUsersRouteConfig; + routeHandler = fixUsersRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('fails if cannot retrieve users', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).not.toHaveBeenCalled(); + }); + + it('fails if fails to update user', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA', 'kibana_user'] }), + userB: createMockUser({ username: 'userB', roles: ['kibana_user'] }), + }, + }) + ); + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledTimes(1); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userA', + body: createMockUser({ username: 'userA', roles: ['roleA', 'kibana_admin'] }), + }); + }); + + it('does nothing if there are no users with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).not.toHaveBeenCalled(); + }); + + it('updates users with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + userE: createMockUser({ + username: 'userE', + roles: ['kibana_user', 'kibana_admin', 'roleE'], + }), + }, + }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledTimes(3); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userB', + body: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userD', + body: createMockUser({ username: 'userD', roles: ['kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userE', + body: createMockUser({ username: 'userE', roles: ['kibana_admin', 'roleE'] }), + }); + }); + }); + + describe('Role mappings with Kibana user role', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [fixRoleMappingsRouteConfig, fixRoleMappingsRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => + path === '/internal/security/deprecations/kibana_user_role/_fix_role_mappings' + )!; + + routeConfig = fixRoleMappingsRouteConfig; + routeHandler = fixRoleMappingsRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('fails if cannot retrieve role mappings', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).not.toHaveBeenCalled(); + }); + + it('fails if fails to update role mapping', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA', 'kibana_user'] }), + mappingB: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledTimes(1); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingA', + body: createMockRoleMapping({ roles: ['roleA', 'kibana_admin'] }), + }); + }); + + it('does nothing if there are no role mappings with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).not.toHaveBeenCalled(); + }); + + it('updates role mappings with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + mappingE: createMockRoleMapping({ roles: ['kibana_user', 'kibana_admin', 'roleE'] }), + }, + }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledTimes(3); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingB', + body: createMockRoleMapping({ roles: ['roleB', 'kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingD', + body: createMockRoleMapping({ roles: ['kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingE', + body: createMockRoleMapping({ roles: ['kibana_admin', 'roleE'] }), + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts new file mode 100644 index 0000000000000..21bb9db7329b6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts @@ -0,0 +1,145 @@ +/* + * 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 { + SecurityGetRoleMappingResponse, + SecurityGetUserResponse, +} from '@elastic/elasticsearch/api/types'; + +import type { RouteDefinitionParams } from '..'; +import { KIBANA_ADMIN_ROLE_NAME, KIBANA_USER_ROLE_NAME } from '../../deprecations'; +import { + getDetailedErrorMessage, + getErrorStatusCode, + wrapIntoCustomErrorResponse, +} from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +/** + * Defines routes required to handle `kibana_user` deprecation. + */ +export function defineKibanaUserRoleDeprecationRoutes({ router, logger }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/deprecations/kibana_user_role/_fix_users', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + let users: SecurityGetUserResponse; + try { + users = (await context.core.elasticsearch.client.asCurrentUser.security.getUser()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve users when checking for deprecations: the manage_security cluster privilege is required` + ); + } else { + logger.error( + `Failed to retrieve users when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}` + ); + } + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + const usersWithKibanaUserRole = Object.values(users).filter((user) => + user.roles.includes(KIBANA_USER_ROLE_NAME) + ); + + if (usersWithKibanaUserRole.length === 0) { + logger.debug(`No users with "${KIBANA_USER_ROLE_NAME}" role found.`); + } else { + logger.debug( + `The following users with "${KIBANA_USER_ROLE_NAME}" role found and will be migrated to "${KIBANA_ADMIN_ROLE_NAME}" role: ${usersWithKibanaUserRole + .map((user) => user.username) + .join(', ')}.` + ); + } + + for (const userToUpdate of usersWithKibanaUserRole) { + const roles = userToUpdate.roles.filter((role) => role !== KIBANA_USER_ROLE_NAME); + if (!roles.includes(KIBANA_ADMIN_ROLE_NAME)) { + roles.push(KIBANA_ADMIN_ROLE_NAME); + } + + try { + await context.core.elasticsearch.client.asCurrentUser.security.putUser({ + username: userToUpdate.username, + body: { ...userToUpdate, roles }, + }); + } catch (err) { + logger.error( + `Failed to update user "${userToUpdate.username}": ${getDetailedErrorMessage(err)}.` + ); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + logger.debug(`Successfully updated user "${userToUpdate.username}".`); + } + + return response.ok({ body: {} }); + }) + ); + + router.post( + { + path: '/internal/security/deprecations/kibana_user_role/_fix_role_mappings', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + let roleMappings: SecurityGetRoleMappingResponse; + try { + roleMappings = ( + await context.core.elasticsearch.client.asCurrentUser.security.getRoleMapping() + ).body; + } catch (err) { + logger.error(`Failed to retrieve role mappings: ${getDetailedErrorMessage(err)}.`); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + const roleMappingsWithKibanaUserRole = Object.entries(roleMappings).filter(([, mapping]) => + mapping.roles.includes(KIBANA_USER_ROLE_NAME) + ); + + if (roleMappingsWithKibanaUserRole.length === 0) { + logger.debug(`No role mappings with "${KIBANA_USER_ROLE_NAME}" role found.`); + } else { + logger.debug( + `The following role mappings with "${KIBANA_USER_ROLE_NAME}" role found and will be migrated to "${KIBANA_ADMIN_ROLE_NAME}" role: ${roleMappingsWithKibanaUserRole + .map(([mappingName]) => mappingName) + .join(', ')}.` + ); + } + + for (const [mappingNameToUpdate, mappingToUpdate] of roleMappingsWithKibanaUserRole) { + const roles = mappingToUpdate.roles.filter((role) => role !== KIBANA_USER_ROLE_NAME); + if (!roles.includes(KIBANA_ADMIN_ROLE_NAME)) { + roles.push(KIBANA_ADMIN_ROLE_NAME); + } + + try { + await context.core.elasticsearch.client.asCurrentUser.security.putRoleMapping({ + name: mappingNameToUpdate, + body: { ...mappingToUpdate, roles }, + }); + } catch (err) { + logger.error( + `Failed to update role mapping "${mappingNameToUpdate}": ${getDetailedErrorMessage( + err + )}.` + ); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + logger.debug(`Successfully updated role mapping "${mappingNameToUpdate}".`); + } + + return response.ok({ body: {} }); + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 851e70a357cf9..6785fe57c6b32 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -11,7 +11,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { HttpResources, IBasePath, Logger } from 'src/core/server'; import type { KibanaFeature } from '../../../features/server'; -import type { SecurityLicense } from '../../common/licensing'; +import type { SecurityLicense } from '../../common'; import type { AnonymousAccessServiceStart } from '../anonymous_access'; import type { InternalAuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetupInternal } from '../authorization'; @@ -23,6 +23,7 @@ import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; +import { defineDeprecationsRoutes } from './deprecations'; import { defineIndicesRoutes } from './indices'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineSecurityCheckupGetStateRoutes } from './security_checkup'; @@ -58,6 +59,7 @@ export function defineRoutes(params: RouteDefinitionParams) { defineUsersRoutes(params); defineRoleMappingRoutes(params); defineViewRoutes(params); + defineDeprecationsRoutes(params); defineAnonymousAccessRoutes(params); defineSecurityCheckupGetStateRoutes(params); } From 3daeff1b521bc6c157c29bbba963a450c862265f Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:14:24 -0400 Subject: [PATCH 15/54] [App Search] Wire up ignored queries panel for curations history view (#115238) (#115402) Co-authored-by: Byron Hulcher --- .../ignored_queries_logic.test.ts | 215 ++++++++++++++++++ .../ignored_queries_logic.ts | 141 ++++++++++++ .../ignored_queries_panel.test.tsx | 93 ++++++++ .../ignored_queries_panel.tsx | 105 +++++++++ .../components/ignored_queries_panel/index.ts | 8 + .../ignored_suggestions_panel.test.tsx | 25 -- .../components/ignored_suggestions_panel.tsx | 53 ----- .../curations_history/components/index.ts | 2 +- .../curations_history.test.tsx | 8 +- .../curations_history/curations_history.tsx | 8 +- 10 files changed, 567 insertions(+), 91 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/index.ts delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.test.tsx delete mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.tsx diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.test.ts new file mode 100644 index 0000000000000..83a200943256b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.test.ts @@ -0,0 +1,215 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, +} from '../../../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/engine_logic.mock'; + +// I don't know why eslint is saying this line is out of order +// eslint-disable-next-line import/order +import { nextTick } from '@kbn/test/jest'; + +import { DEFAULT_META } from '../../../../../../../shared/constants'; + +import { IgnoredQueriesLogic } from './ignored_queries_logic'; + +const DEFAULT_VALUES = { + dataLoading: true, + ignoredQueries: [], + meta: { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + size: 10, + }, + }, +}; + +describe('IgnoredQueriesLogic', () => { + const { mount } = new LogicMounter(IgnoredQueriesLogic); + const { flashAPIErrors, flashSuccessToast } = mockFlashMessageHelpers; + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + mount(); + }); + + it('has expected default values', () => { + expect(IgnoredQueriesLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onIgnoredQueriesLoad', () => { + it('should set queries, meta state, & dataLoading to false', () => { + IgnoredQueriesLogic.actions.onIgnoredQueriesLoad(['first query', 'second query'], { + page: { + current: 1, + size: 10, + total_results: 1, + total_pages: 1, + }, + }); + + expect(IgnoredQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + ignoredQueries: ['first query', 'second query'], + meta: { + page: { + current: 1, + size: 10, + total_results: 1, + total_pages: 1, + }, + }, + dataLoading: false, + }); + }); + }); + + describe('onPaginate', () => { + it('should update meta', () => { + IgnoredQueriesLogic.actions.onPaginate(2); + + expect(IgnoredQueriesLogic.values).toEqual({ + ...DEFAULT_VALUES, + meta: { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + current: 2, + }, + }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadIgnoredQueries', () => { + it('should make an API call and set suggestions & meta state', async () => { + http.post.mockReturnValueOnce( + Promise.resolve({ + results: [{ query: 'first query' }, { query: 'second query' }], + meta: { + page: { + current: 1, + size: 10, + total_results: 1, + total_pages: 1, + }, + }, + }) + ); + jest.spyOn(IgnoredQueriesLogic.actions, 'onIgnoredQueriesLoad'); + + IgnoredQueriesLogic.actions.loadIgnoredQueries(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions', + { + body: JSON.stringify({ + page: { + current: 1, + size: 10, + }, + filters: { + status: ['disabled'], + type: 'curation', + }, + }), + } + ); + + expect(IgnoredQueriesLogic.actions.onIgnoredQueriesLoad).toHaveBeenCalledWith( + ['first query', 'second query'], + { + page: { + current: 1, + size: 10, + total_results: 1, + total_pages: 1, + }, + } + ); + }); + + it('handles errors', async () => { + http.post.mockReturnValueOnce(Promise.reject('error')); + + IgnoredQueriesLogic.actions.loadIgnoredQueries(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + + describe('allowIgnoredQuery', () => { + it('will make an http call to reject the suggestion for the query', async () => { + http.put.mockReturnValueOnce( + Promise.resolve({ + results: [ + { + query: 'test query', + type: 'curation', + status: 'rejected', + }, + ], + }) + ); + + IgnoredQueriesLogic.actions.allowIgnoredQuery('test query'); + await nextTick(); + + expect(http.put).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions', + { + body: JSON.stringify([ + { + query: 'test query', + type: 'curation', + status: 'rejected', + }, + ]), + } + ); + + expect(flashSuccessToast).toHaveBeenCalledWith(expect.any(String)); + }); + + it('handles errors', async () => { + http.put.mockReturnValueOnce(Promise.reject('error')); + + IgnoredQueriesLogic.actions.allowIgnoredQuery('test query'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + + it('handles inline errors', async () => { + http.put.mockReturnValueOnce( + Promise.resolve({ + results: [ + { + error: 'error', + }, + ], + }) + ); + + IgnoredQueriesLogic.actions.allowIgnoredQuery('test query'); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.ts new file mode 100644 index 0000000000000..e36b5bc156b46 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_logic.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { Meta } from '../../../../../../../../../common/types'; +import { DEFAULT_META } from '../../../../../../../shared/constants'; +import { flashAPIErrors, flashSuccessToast } from '../../../../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../../../../shared/http'; +import { updateMetaPageIndex } from '../../../../../../../shared/table_pagination'; +import { EngineLogic } from '../../../../../engine'; +import { CurationSuggestion } from '../../../../types'; + +interface IgnoredQueriesValues { + dataLoading: boolean; + ignoredQueries: string[]; + meta: Meta; +} + +interface IgnoredQueriesActions { + allowIgnoredQuery(ignoredQuery: string): { + ignoredQuery: string; + }; + loadIgnoredQueries(): void; + onIgnoredQueriesLoad( + ignoredQueries: string[], + meta: Meta + ): { ignoredQueries: string[]; meta: Meta }; + onPaginate(newPageIndex: number): { newPageIndex: number }; +} + +interface SuggestionUpdateError { + error: string; +} + +const ALLOW_SUCCESS_MESSAGE = i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestionsPanel.allowQuerySuccessMessage', + { + defaultMessage: 'You’ll be notified about future suggestions for this query', + } +); + +export const IgnoredQueriesLogic = kea>({ + path: ['enterprise_search', 'app_search', 'curations', 'ignored_queries_panel_logic'], + actions: () => ({ + allowIgnoredQuery: (ignoredQuery) => ({ ignoredQuery }), + loadIgnoredQueries: true, + onIgnoredQueriesLoad: (ignoredQueries, meta) => ({ ignoredQueries, meta }), + onPaginate: (newPageIndex) => ({ newPageIndex }), + }), + reducers: () => ({ + dataLoading: [ + true, + { + onIgnoredQueriesLoad: () => false, + }, + ], + ignoredQueries: [ + [], + { + onIgnoredQueriesLoad: (_, { ignoredQueries }) => ignoredQueries, + }, + ], + meta: [ + { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + size: 10, + }, + }, + { + onIgnoredQueriesLoad: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + }), + listeners: ({ actions, values }) => ({ + loadIgnoredQueries: async () => { + const { meta } = values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response: { results: CurationSuggestion[]; meta: Meta } = await http.post( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions`, + { + body: JSON.stringify({ + page: { + current: meta.page.current, + size: meta.page.size, + }, + filters: { + status: ['disabled'], + type: 'curation', + }, + }), + } + ); + + const queries = response.results.map((suggestion) => suggestion.query); + actions.onIgnoredQueriesLoad(queries, response.meta); + } catch (e) { + flashAPIErrors(e); + } + }, + allowIgnoredQuery: async ({ ignoredQuery }) => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + try { + const response = await http.put<{ + results: Array; + }>(`/internal/app_search/engines/${engineName}/search_relevance_suggestions`, { + body: JSON.stringify([ + { + query: ignoredQuery, + type: 'curation', + status: 'rejected', + }, + ]), + }); + + if (response.results[0].hasOwnProperty('error')) { + throw (response.results[0] as SuggestionUpdateError).error; + } + + flashSuccessToast(ALLOW_SUCCESS_MESSAGE); + // re-loading to update the current page rather than manually remove the query + actions.loadIgnoredQueries(); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.test.tsx new file mode 100644 index 0000000000000..919e1e8706c94 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.test.tsx @@ -0,0 +1,93 @@ +/* + * 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 '../../../../../../../__mocks__/shallow_useeffect.mock'; +// I don't know why eslint is saying this line is out of order +// eslint-disable-next-line import/order +import { setMockActions, setMockValues } from '../../../../../../../__mocks__/kea_logic'; +import '../../../../../../__mocks__/engine_logic.mock'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiBasicTable } from '@elastic/eui'; + +import { IgnoredQueriesPanel } from './ignored_queries_panel'; + +describe('IgnoredQueriesPanel', () => { + const values = { + dataLoading: false, + suggestions: [ + { + query: 'foo', + updated_at: '2021-07-08T14:35:50Z', + promoted: ['1', '2'], + }, + ], + meta: { + page: { + current: 1, + size: 10, + total_results: 2, + }, + }, + }; + + const mockActions = { + allowIgnoredQuery: jest.fn(), + loadIgnoredQueries: jest.fn(), + onPaginate: jest.fn(), + }; + + beforeAll(() => { + setMockValues(values); + setMockActions(mockActions); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const getColumn = (index: number) => { + const wrapper = shallow(); + const table = wrapper.find(EuiBasicTable); + const columns = table.prop('columns'); + return columns[index]; + }; + + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiBasicTable).exists()).toBe(true); + }); + + it('show a query', () => { + const column = getColumn(0).render('test query'); + expect(column).toEqual('test query'); + }); + + it('has an allow action', () => { + const column = getColumn(1); + // @ts-ignore + const actions = column.actions; + actions[0].onClick('test query'); + expect(mockActions.allowIgnoredQuery).toHaveBeenCalledWith('test query'); + }); + + it('fetches data on load', () => { + shallow(); + + expect(mockActions.loadIgnoredQueries).toHaveBeenCalled(); + }); + + it('supports pagination', () => { + const wrapper = shallow(); + wrapper.find(EuiBasicTable).simulate('change', { page: { index: 0 } }); + + expect(mockActions.onPaginate).toHaveBeenCalledWith(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx new file mode 100644 index 0000000000000..f7cc192932332 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/ignored_queries_panel.tsx @@ -0,0 +1,105 @@ +/* + * 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, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { + convertMetaToPagination, + handlePageChange, +} from '../../../../../../../shared/table_pagination'; + +import { DataPanel } from '../../../../../data_panel'; + +import { IgnoredQueriesLogic } from './ignored_queries_logic'; + +export const IgnoredQueriesPanel: React.FC = () => { + const { dataLoading, ignoredQueries, meta } = useValues(IgnoredQueriesLogic); + const { allowIgnoredQuery, loadIgnoredQueries, onPaginate } = useActions(IgnoredQueriesLogic); + + useEffect(() => { + loadIgnoredQueries(); + }, [meta.page.current]); + + const columns: Array> = [ + { + render: (query: string) => query, + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestionsPanel.queryColumnName', + { + defaultMessage: 'Query', + } + ), + }, + { + actions: [ + { + type: 'button', + name: i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestions.allowButtonLabel', + { + defaultMessage: 'Allow', + } + ), + description: i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestions.allowButtonDescription', + { + defaultMessage: 'Enable suggestions for this query', + } + ), + onClick: (query) => allowIgnoredQuery(query), + color: 'primary', + }, + ], + }, + ]; + + return ( + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestionsPanel.title', + { + defaultMessage: 'Ignored queries', + } + )} + + } + subtitle={ + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.curations.ignoredSuggestionsPanel.description', + { + defaultMessage: 'You won’t be notified about suggestions for these queries', + } + )} + + } + iconType="eyeClosed" + hasBorder + > + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/index.ts new file mode 100644 index 0000000000000..f4cb73919f42f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_queries_panel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { IgnoredQueriesPanel } from './ignored_queries_panel'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.test.tsx deleted file mode 100644 index b09981748f19c..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.test.tsx +++ /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 React from 'react'; - -import { shallow } from 'enzyme'; - -import { EuiBasicTable } from '@elastic/eui'; - -import { DataPanel } from '../../../../data_panel'; - -import { IgnoredSuggestionsPanel } from './ignored_suggestions_panel'; - -describe('IgnoredSuggestionsPanel', () => { - it('renders', () => { - const wrapper = shallow(); - - expect(wrapper.is(DataPanel)).toBe(true); - expect(wrapper.find(EuiBasicTable)).toHaveLength(1); - }); -}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.tsx deleted file mode 100644 index f2fdfd55a7e5a..0000000000000 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/ignored_suggestions_panel.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; - -import { CustomItemAction, EuiBasicTable, EuiBasicTableColumn, EuiLink } from '@elastic/eui'; - -import { DataPanel } from '../../../../data_panel'; -import { CurationSuggestion } from '../../../types'; - -export const IgnoredSuggestionsPanel: React.FC = () => { - const ignoredSuggestions: CurationSuggestion[] = []; - - const allowSuggestion = (query: string) => alert(query); - - const actions: Array> = [ - { - render: (item: CurationSuggestion) => { - return ( - allowSuggestion(item.query)} color="primary"> - Allow - - ); - }, - }, - ]; - - const columns: Array> = [ - { - field: 'query', - name: 'Query', - sortable: true, - }, - { - actions, - }, - ]; - - return ( - Ignored queries} - subtitle={You won’t be notified about suggestions for these queries} - iconType="eyeClosed" - hasBorder - > - - - ); -}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/index.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/index.ts index 2e16d9bde8550..43651e613364e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/index.ts @@ -6,5 +6,5 @@ */ export { CurationChangesPanel } from './curation_changes_panel'; -export { IgnoredSuggestionsPanel } from './ignored_suggestions_panel'; +export { IgnoredQueriesPanel } from './ignored_queries_panel'; export { RejectedCurationsPanel } from './rejected_curations_panel'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.test.tsx index 1ebd4da694d54..407454922ef05 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.test.tsx @@ -9,11 +9,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { - CurationChangesPanel, - IgnoredSuggestionsPanel, - RejectedCurationsPanel, -} from './components'; +import { CurationChangesPanel, IgnoredQueriesPanel, RejectedCurationsPanel } from './components'; import { CurationsHistory } from './curations_history'; describe('CurationsHistory', () => { @@ -22,6 +18,6 @@ describe('CurationsHistory', () => { expect(wrapper.find(CurationChangesPanel)).toHaveLength(1); expect(wrapper.find(RejectedCurationsPanel)).toHaveLength(1); - expect(wrapper.find(IgnoredSuggestionsPanel)).toHaveLength(1); + expect(wrapper.find(IgnoredQueriesPanel)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.tsx index 6db62820b1cdb..5f857087e05ef 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/curations_history.tsx @@ -9,11 +9,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { - CurationChangesPanel, - IgnoredSuggestionsPanel, - RejectedCurationsPanel, -} from './components'; +import { CurationChangesPanel, IgnoredQueriesPanel, RejectedCurationsPanel } from './components'; export const CurationsHistory: React.FC = () => { return ( @@ -29,7 +25,7 @@ export const CurationsHistory: React.FC = () => { - + ); From 01dac44361ccf2630d411814d2852f3e9f172ea3 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:33:56 -0400 Subject: [PATCH 16/54] [Fleet] Fix for NaN agents in modal when package is not installed (#115361) (#115403) Co-authored-by: Cristina Amico --- .../screens/detail/settings/update_button.tsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 48569d782a70b..2acd5634b1e5f 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 @@ -114,18 +114,20 @@ export const UpdateButton: React.FunctionComponent = ({ return Array.isArray(arr) && arr.every((p) => typeof p === 'string'); } - const agentCount = useMemo( - () => - agentPolicyData?.items.reduce((acc, item) => { - const existingPolicies = isStringArray(item?.package_policies) - ? (item?.package_policies as string[]).filter((p) => packagePolicyIds.includes(p)) - : (item?.package_policies as PackagePolicy[]).filter((p) => + const agentCount = useMemo(() => { + if (!agentPolicyData?.items) return 0; + + return agentPolicyData.items.reduce((acc, item) => { + const existingPolicies = item?.package_policies + ? isStringArray(item.package_policies) + ? (item.package_policies as string[]).filter((p) => packagePolicyIds.includes(p)) + : (item.package_policies as PackagePolicy[]).filter((p) => packagePolicyIds.includes(p.id) - ); - return (acc += existingPolicies.length > 0 && item?.agents ? item?.agents : 0); - }, 0), - [agentPolicyData, packagePolicyIds] - ); + ) + : []; + return (acc += existingPolicies.length > 0 && item?.agents ? item?.agents : 0); + }, 0); + }, [agentPolicyData, packagePolicyIds]); const conflictCount = useMemo( () => dryRunData?.filter((item) => item.hasErrors).length, From daaf60d822a526d7dd270ff2bf52493c97926676 Mon Sep 17 00:00:00 2001 From: Dmitry Borodyansky Date: Mon, 18 Oct 2021 11:37:19 -0700 Subject: [PATCH 17/54] [Upgrade Assistant] Overview page UI clean up (#115258) - Scaling down deprecation issue panel title size to flow with typographic hierarchy. - Removing panel around deprecation logging switch to reduce visual elements. - Using success instead of green color for migration complete message. --- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../components/deprecation_issues_panel.tsx | 3 +- .../overview/fix_logs_step/fix_logs_step.tsx | 29 +++++++------------ .../migrate_system_indices.tsx | 2 +- 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1e538b63e37d6..52521b139d0fe 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25640,7 +25640,6 @@ "xpack.upgradeAssistant.overview.observe.observabilityDescription": "使用中のAPIのうち廃止予定のAPIと、更新が必要なアプリケーションを特定できます。", "xpack.upgradeAssistant.overview.pageDescription": "次のバージョンのElastic Stackをお待ちください。", "xpack.upgradeAssistant.overview.pageTitle": "アップグレードアシスタント", - "xpack.upgradeAssistant.overview.toggleTitle": "Elasticsearch廃止予定警告をログに出力", "xpack.upgradeAssistant.overview.upgradeGuideLink": "アップグレードガイドを表示", "xpack.upgradeAssistant.overview.upgradeStepCloudLink": "クラウドでアップグレード", "xpack.upgradeAssistant.overview.upgradeStepDescription": "重要な問題をすべて解決し、アプリケーションの準備を確認した後に、Elastic Stackをアップグレードできます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c0d2970983166..a139b4f2fa240 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -26073,7 +26073,6 @@ "xpack.upgradeAssistant.overview.observe.observabilityDescription": "深入了解正在使用哪些已弃用 API 以及需要更新哪些应用程序。", "xpack.upgradeAssistant.overview.pageDescription": "准备使用下一版 Elastic Stack!", "xpack.upgradeAssistant.overview.pageTitle": "升级助手", - "xpack.upgradeAssistant.overview.toggleTitle": "记录 Elasticsearch 弃用警告", "xpack.upgradeAssistant.overview.upgradeGuideLink": "查看升级指南", "xpack.upgradeAssistant.overview.upgradeStepCloudLink": "在 Cloud 上升级", "xpack.upgradeAssistant.overview.upgradeStepDescription": "解决所有关键问题并确认您的应用程序就绪后,便可以升级 Elastic Stack。", diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/components/deprecation_issues_panel.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/components/deprecation_issues_panel.tsx index 084e4528694dd..8c42e71c0ef2b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/components/deprecation_issues_panel.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/components/deprecation_issues_panel.tsx @@ -73,9 +73,10 @@ export const DeprecationIssuesPanel = (props: Props) => { className="upgDeprecationIssuesPanel" layout="horizontal" title={deprecationSource} + titleSize="xs" {...(!hasNoIssues && reactRouterNavigate(history, linkUrl))} > - + {hasError ? ( {errorMessage} diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/fix_logs_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/fix_logs_step.tsx index 42ab75a3ee3f4..aeba230a5b27b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/fix_logs_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/fix_logs_step.tsx @@ -8,7 +8,7 @@ import React, { FunctionComponent, useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiText, EuiSpacer, EuiPanel, EuiLink, EuiCallOut, EuiCode } from '@elastic/eui'; +import { EuiText, EuiSpacer, EuiLink, EuiCallOut, EuiCode } from '@elastic/eui'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; import { useAppContext } from '../../../app_context'; @@ -25,9 +25,6 @@ const i18nTexts = { identifyStepTitle: i18n.translate('xpack.upgradeAssistant.overview.identifyStepTitle', { defaultMessage: 'Identify deprecated API use and update your applications', }), - toggleTitle: i18n.translate('xpack.upgradeAssistant.overview.toggleTitle', { - defaultMessage: 'Log Elasticsearch deprecation issues', - }), analyzeTitle: i18n.translate('xpack.upgradeAssistant.overview.analyzeTitle', { defaultMessage: 'Analyze deprecation logs', }), @@ -140,21 +137,15 @@ const FixLogsStep: FunctionComponent = ({ return ( <> - -

{i18nTexts.toggleTitle}

-
- - - - + {onlyDeprecationLogWritingEnabled && ( <> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx index ab370c99bfc58..75806f43bb19e 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/migrate_system_indices/migrate_system_indices.tsx @@ -118,7 +118,7 @@ const MigrateSystemIndicesStep: FunctionComponent = ({ setIsComplete }) = - +

{i18nTexts.noMigrationNeeded}

From f106f0271d326745aaf5710c8d78ae28a16f2e36 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 14:55:38 -0400 Subject: [PATCH 18/54] [Stack monitoring] Remove angular (#115063) (#115406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove angular * Fix translations * convert insetupmode to boolean * remove license service Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Ester Martí Vilaseca --- x-pack/plugins/monitoring/kibana.json | 1 - .../monitoring/public/alerts/badge.tsx | 2 +- .../monitoring/public/angular/app_modules.ts | 246 ------------- .../public/angular/helpers/routes.ts | 39 --- .../public/angular/helpers/utils.ts | 45 --- .../monitoring/public/angular/index.ts | 83 ----- .../public/angular/providers/private.js | 193 ----------- .../application/pages/logstash/pipelines.tsx | 8 +- .../pages/no_data/no_data_page.tsx | 2 +- .../application/pages/page_template.tsx | 2 +- .../public/application/setup_mode/index.ts | 2 +- .../application/setup_mode/setup_mode.tsx | 203 ----------- .../setup_mode/setup_mode_renderer.js | 2 +- .../elasticsearch/ml_job_listing/index.js | 171 ---------- .../public/directives/main/index.html | 323 ------------------ .../public/directives/main/index.js | 275 --------------- .../public/directives/main/index.scss | 3 - .../main/monitoring_main_controller.test.js | 286 ---------------- .../monitoring/public/lib/setup_mode.test.js | 2 +- .../monitoring/public/lib/setup_mode.tsx | 113 +++--- x-pack/plugins/monitoring/public/plugin.ts | 48 +-- .../monitoring/public/services/breadcrumbs.js | 214 ------------ .../public/services/breadcrumbs.test.js | 166 --------- .../monitoring/public/services/clusters.js | 59 ---- .../public/services/enable_alerts_modal.js | 51 --- .../monitoring/public/services/executor.js | 130 ------- .../monitoring/public/services/features.js | 47 --- .../monitoring/public/services/license.js | 52 --- .../monitoring/public/services/title.js | 26 -- .../public/views/access_denied/index.html | 44 --- .../public/views/access_denied/index.js | 44 --- x-pack/plugins/monitoring/public/views/all.js | 39 --- .../public/views/apm/instance/index.html | 8 - .../public/views/apm/instance/index.js | 74 ---- .../public/views/apm/instances/index.html | 7 - .../public/views/apm/instances/index.js | 92 ----- .../public/views/apm/overview/index.html | 7 - .../public/views/apm/overview/index.js | 58 ---- .../public/views/base_controller.js | 271 --------------- .../public/views/base_eui_table_controller.js | 135 -------- .../public/views/base_table_controller.js | 53 --- .../public/views/beats/beat/get_page_data.js | 32 -- .../public/views/beats/beat/index.html | 11 - .../public/views/beats/beat/index.js | 75 ---- .../views/beats/listing/get_page_data.js | 31 -- .../public/views/beats/listing/index.html | 7 - .../public/views/beats/listing/index.js | 89 ----- .../views/beats/overview/get_page_data.js | 31 -- .../public/views/beats/overview/index.html | 7 - .../public/views/beats/overview/index.js | 62 ---- .../public/views/cluster/listing/index.html | 3 - .../public/views/cluster/listing/index.js | 100 ------ .../public/views/cluster/overview/index.html | 3 - .../public/views/cluster/overview/index.js | 96 ------ .../views/elasticsearch/ccr/get_page_data.js | 31 -- .../public/views/elasticsearch/ccr/index.html | 7 - .../public/views/elasticsearch/ccr/index.js | 79 ----- .../elasticsearch/ccr/shard/get_page_data.js | 32 -- .../views/elasticsearch/ccr/shard/index.html | 8 - .../views/elasticsearch/ccr/shard/index.js | 110 ------ .../elasticsearch/index/advanced/index.html | 8 - .../elasticsearch/index/advanced/index.js | 124 ------- .../views/elasticsearch/index/index.html | 9 - .../public/views/elasticsearch/index/index.js | 148 -------- .../views/elasticsearch/indices/index.html | 8 - .../views/elasticsearch/indices/index.js | 120 ------- .../elasticsearch/ml_jobs/get_page_data.js | 30 -- .../views/elasticsearch/ml_jobs/index.html | 9 - .../views/elasticsearch/ml_jobs/index.js | 51 --- .../elasticsearch/node/advanced/index.html | 11 - .../elasticsearch/node/advanced/index.js | 135 -------- .../views/elasticsearch/node/get_page_data.js | 36 -- .../views/elasticsearch/node/index.html | 11 - .../public/views/elasticsearch/node/index.js | 155 --------- .../views/elasticsearch/nodes/index.html | 8 - .../public/views/elasticsearch/nodes/index.js | 149 -------- .../elasticsearch/overview/controller.js | 100 ------ .../views/elasticsearch/overview/index.html | 8 - .../views/elasticsearch/overview/index.js | 24 -- .../plugins/monitoring/public/views/index.js | 10 - .../public/views/kibana/instance/index.html | 8 - .../public/views/kibana/instance/index.js | 168 --------- .../views/kibana/instances/get_page_data.js | 31 -- .../public/views/kibana/instances/index.html | 7 - .../public/views/kibana/instances/index.js | 95 ------ .../public/views/kibana/overview/index.html | 7 - .../public/views/kibana/overview/index.js | 117 ------- .../public/views/license/controller.js | 79 ----- .../public/views/license/index.html | 3 - .../monitoring/public/views/license/index.js | 24 -- .../public/views/loading/index.html | 5 - .../monitoring/public/views/loading/index.js | 78 ----- .../views/logstash/node/advanced/index.html | 9 - .../views/logstash/node/advanced/index.js | 149 -------- .../public/views/logstash/node/index.html | 9 - .../public/views/logstash/node/index.js | 147 -------- .../views/logstash/node/pipelines/index.html | 8 - .../views/logstash/node/pipelines/index.js | 135 -------- .../views/logstash/nodes/get_page_data.js | 31 -- .../public/views/logstash/nodes/index.html | 3 - .../public/views/logstash/nodes/index.js | 89 ----- .../public/views/logstash/overview/index.html | 3 - .../public/views/logstash/overview/index.js | 81 ----- .../public/views/logstash/pipeline/index.html | 12 - .../public/views/logstash/pipeline/index.js | 181 ---------- .../views/logstash/pipelines/index.html | 7 - .../public/views/logstash/pipelines/index.js | 130 ------- .../public/views/no_data/controller.js | 102 ------ .../public/views/no_data/index.html | 5 - .../monitoring/public/views/no_data/index.js | 15 - .../public/views/no_data/model_updater.js | 38 --- .../views/no_data/model_updater.test.js | 55 --- .../translations/translations/ja-JP.json | 21 +- .../translations/translations/zh-CN.json | 21 +- 114 files changed, 68 insertions(+), 7399 deletions(-) delete mode 100644 x-pack/plugins/monitoring/public/angular/app_modules.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/helpers/routes.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/helpers/utils.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/index.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/providers/private.js delete mode 100644 x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx delete mode 100644 x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js delete mode 100644 x-pack/plugins/monitoring/public/directives/main/index.html delete mode 100644 x-pack/plugins/monitoring/public/directives/main/index.js delete mode 100644 x-pack/plugins/monitoring/public/directives/main/index.scss delete mode 100644 x-pack/plugins/monitoring/public/directives/main/monitoring_main_controller.test.js delete mode 100644 x-pack/plugins/monitoring/public/services/breadcrumbs.js delete mode 100644 x-pack/plugins/monitoring/public/services/breadcrumbs.test.js delete mode 100644 x-pack/plugins/monitoring/public/services/clusters.js delete mode 100644 x-pack/plugins/monitoring/public/services/enable_alerts_modal.js delete mode 100644 x-pack/plugins/monitoring/public/services/executor.js delete mode 100644 x-pack/plugins/monitoring/public/services/features.js delete mode 100644 x-pack/plugins/monitoring/public/services/license.js delete mode 100644 x-pack/plugins/monitoring/public/services/title.js delete mode 100644 x-pack/plugins/monitoring/public/views/access_denied/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/access_denied/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/all.js delete mode 100644 x-pack/plugins/monitoring/public/views/apm/instance/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/apm/instance/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/apm/instances/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/apm/instances/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/apm/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/apm/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/base_controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/base_eui_table_controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/base_table_controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/beat/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/beat/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/beats/beat/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/listing/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/listing/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/beats/listing/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/overview/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/beats/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/beats/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/cluster/listing/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/cluster/listing/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/cluster/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/cluster/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/index/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/index/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/node/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/overview/controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/instance/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/instance/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/instances/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/instances/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/instances/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/kibana/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/license/controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/license/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/license/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/loading/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/loading/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/nodes/get_page_data.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/nodes/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/nodes/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/overview/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/overview/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/pipeline/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/pipeline/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/pipelines/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/logstash/pipelines/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/no_data/controller.js delete mode 100644 x-pack/plugins/monitoring/public/views/no_data/index.html delete mode 100644 x-pack/plugins/monitoring/public/views/no_data/index.js delete mode 100644 x-pack/plugins/monitoring/public/views/no_data/model_updater.js delete mode 100644 x-pack/plugins/monitoring/public/views/no_data/model_updater.test.js diff --git a/x-pack/plugins/monitoring/kibana.json b/x-pack/plugins/monitoring/kibana.json index 4f8e1c0bdbae4..bc0cf47181585 100644 --- a/x-pack/plugins/monitoring/kibana.json +++ b/x-pack/plugins/monitoring/kibana.json @@ -25,7 +25,6 @@ "home", "alerting", "kibanaReact", - "licenseManagement", "kibanaLegacy" ] } diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index 6b1c8c5085565..22bffb5d62b19 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -73,7 +73,7 @@ export const AlertsBadge: React.FC = (props: Props) => { const groupByType = GROUP_BY_NODE; const panels = showByNode ? getAlertPanelsByNode(PANEL_TITLE, alerts, stateFilter) - : getAlertPanelsByCategory(PANEL_TITLE, inSetupMode, alerts, stateFilter); + : getAlertPanelsByCategory(PANEL_TITLE, !!inSetupMode, alerts, stateFilter); if (panels.length && !inSetupMode && panels[0].items) { panels[0].items.push( ...[ diff --git a/x-pack/plugins/monitoring/public/angular/app_modules.ts b/x-pack/plugins/monitoring/public/angular/app_modules.ts deleted file mode 100644 index 6ded0bce51d4b..0000000000000 --- a/x-pack/plugins/monitoring/public/angular/app_modules.ts +++ /dev/null @@ -1,246 +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, { IWindowService } from 'angular'; -import '../views/all'; -// required for `ngSanitize` angular module -import 'angular-sanitize'; -import 'angular-route'; -import '../index.scss'; -import { upperFirst } from 'lodash'; -import { CoreStart } from 'kibana/public'; -import { i18nDirective, i18nFilter, I18nProvider } from './angular_i18n'; -import { Storage } from '../../../../../src/plugins/kibana_utils/public'; -import { createTopNavDirective, createTopNavHelper } from './top_nav'; -import { MonitoringStartPluginDependencies } from '../types'; -import { GlobalState } from '../url_state'; -import { getSafeForExternalLink } from '../lib/get_safe_for_external_link'; - -// @ts-ignore -import { formatMetric, formatNumber } from '../lib/format_number'; -// @ts-ignore -import { extractIp } from '../lib/extract_ip'; -// @ts-ignore -import { PrivateProvider } from './providers/private'; -// @ts-ignore -import { breadcrumbsProvider } from '../services/breadcrumbs'; -// @ts-ignore -import { monitoringClustersProvider } from '../services/clusters'; -// @ts-ignore -import { executorProvider } from '../services/executor'; -// @ts-ignore -import { featuresProvider } from '../services/features'; -// @ts-ignore -import { licenseProvider } from '../services/license'; -// @ts-ignore -import { titleProvider } from '../services/title'; -// @ts-ignore -import { enableAlertsModalProvider } from '../services/enable_alerts_modal'; -// @ts-ignore -import { monitoringMlListingProvider } from '../directives/elasticsearch/ml_job_listing'; -// @ts-ignore -import { monitoringMainProvider } from '../directives/main'; - -export const appModuleName = 'monitoring'; - -type IPrivate = (provider: (...injectable: unknown[]) => T) => T; - -const thirdPartyAngularDependencies = ['ngSanitize', 'ngRoute', 'react']; - -export const localAppModule = ({ - core, - data: { query }, - navigation, - externalConfig, -}: MonitoringStartPluginDependencies) => { - createLocalI18nModule(); - createLocalPrivateModule(); - createLocalStorage(); - createLocalConfigModule(core); - createLocalStateModule(query, core.notifications.toasts); - createLocalTopNavModule(navigation); - createHrefModule(core); - createMonitoringAppServices(); - createMonitoringAppDirectives(); - createMonitoringAppConfigConstants(externalConfig); - createMonitoringAppFilters(); - - const appModule = angular.module(appModuleName, [ - ...thirdPartyAngularDependencies, - 'monitoring/I18n', - 'monitoring/Private', - 'monitoring/Storage', - 'monitoring/Config', - 'monitoring/State', - 'monitoring/TopNav', - 'monitoring/href', - 'monitoring/constants', - 'monitoring/services', - 'monitoring/filters', - 'monitoring/directives', - ]); - return appModule; -}; - -function createMonitoringAppConfigConstants( - keys: MonitoringStartPluginDependencies['externalConfig'] -) { - let constantsModule = angular.module('monitoring/constants', []); - keys.map(([key, value]) => (constantsModule = constantsModule.constant(key as string, value))); -} - -function createLocalStateModule( - query: MonitoringStartPluginDependencies['data']['query'], - toasts: MonitoringStartPluginDependencies['core']['notifications']['toasts'] -) { - angular - .module('monitoring/State', ['monitoring/Private']) - .service( - 'globalState', - function ( - Private: IPrivate, - $rootScope: ng.IRootScopeService, - $location: ng.ILocationService - ) { - function GlobalStateProvider(this: any) { - const state = new GlobalState(query, toasts, $rootScope, $location, this); - const initialState: any = state.getState(); - for (const key in initialState) { - if (!initialState.hasOwnProperty(key)) { - continue; - } - this[key] = initialState[key]; - } - this.save = () => { - const newState = { ...this }; - delete newState.save; - state.setState(newState); - }; - } - return Private(GlobalStateProvider); - } - ); -} - -function createMonitoringAppServices() { - angular - .module('monitoring/services', ['monitoring/Private']) - .service('breadcrumbs', function (Private: IPrivate) { - return Private(breadcrumbsProvider); - }) - .service('monitoringClusters', function (Private: IPrivate) { - return Private(monitoringClustersProvider); - }) - .service('$executor', function (Private: IPrivate) { - return Private(executorProvider); - }) - .service('features', function (Private: IPrivate) { - return Private(featuresProvider); - }) - .service('enableAlertsModal', function (Private: IPrivate) { - return Private(enableAlertsModalProvider); - }) - .service('license', function (Private: IPrivate) { - return Private(licenseProvider); - }) - .service('title', function (Private: IPrivate) { - return Private(titleProvider); - }); -} - -function createMonitoringAppDirectives() { - angular - .module('monitoring/directives', []) - .directive('monitoringMlListing', monitoringMlListingProvider) - .directive('monitoringMain', monitoringMainProvider); -} - -function createMonitoringAppFilters() { - angular - .module('monitoring/filters', []) - .filter('capitalize', function () { - return function (input: string) { - return upperFirst(input?.toLowerCase()); - }; - }) - .filter('formatNumber', function () { - return formatNumber; - }) - .filter('formatMetric', function () { - return formatMetric; - }) - .filter('extractIp', function () { - return extractIp; - }); -} - -function createLocalConfigModule(core: MonitoringStartPluginDependencies['core']) { - angular.module('monitoring/Config', []).provider('config', function () { - return { - $get: () => ({ - get: (key: string) => core.uiSettings?.get(key), - }), - }; - }); -} - -function createLocalStorage() { - angular - .module('monitoring/Storage', []) - .service('localStorage', function ($window: IWindowService) { - return new Storage($window.localStorage); - }) - .service('sessionStorage', function ($window: IWindowService) { - return new Storage($window.sessionStorage); - }) - .service('sessionTimeout', function () { - return {}; - }); -} - -function createLocalPrivateModule() { - angular.module('monitoring/Private', []).provider('Private', PrivateProvider); -} - -function createLocalTopNavModule({ ui }: MonitoringStartPluginDependencies['navigation']) { - angular - .module('monitoring/TopNav', ['react']) - .directive('kbnTopNav', createTopNavDirective) - .directive('kbnTopNavHelper', createTopNavHelper(ui)); -} - -function createLocalI18nModule() { - angular - .module('monitoring/I18n', []) - .provider('i18n', I18nProvider) - .filter('i18n', i18nFilter) - .directive('i18nId', i18nDirective); -} - -function createHrefModule(core: CoreStart) { - const name: string = 'kbnHref'; - angular.module('monitoring/href', []).directive(name, function () { - return { - restrict: 'A', - link: { - pre: (_$scope, _$el, $attr) => { - $attr.$observe(name, (val) => { - if (val) { - const url = getSafeForExternalLink(val as string); - $attr.$set('href', core.http.basePath.prepend(url)); - } - }); - - _$scope.$on('$locationChangeSuccess', () => { - const url = getSafeForExternalLink($attr.href as string); - $attr.$set('href', core.http.basePath.prepend(url)); - }); - }, - }, - }; - }); -} diff --git a/x-pack/plugins/monitoring/public/angular/helpers/routes.ts b/x-pack/plugins/monitoring/public/angular/helpers/routes.ts deleted file mode 100644 index 2579e522882a2..0000000000000 --- a/x-pack/plugins/monitoring/public/angular/helpers/routes.ts +++ /dev/null @@ -1,39 +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. - */ - -type RouteObject = [string, { reloadOnSearch: boolean }]; -interface Redirect { - redirectTo: string; -} - -class Routes { - private routes: RouteObject[] = []; - public redirect?: Redirect = { redirectTo: '/no-data' }; - - public when = (...args: RouteObject) => { - const [, routeOptions] = args; - routeOptions.reloadOnSearch = false; - this.routes.push(args); - return this; - }; - - public otherwise = (redirect: Redirect) => { - this.redirect = redirect; - return this; - }; - - public addToProvider = ($routeProvider: any) => { - this.routes.forEach((args) => { - $routeProvider.when.apply(this, args); - }); - - if (this.redirect) { - $routeProvider.otherwise(this.redirect); - } - }; -} -export const uiRoutes = new Routes(); diff --git a/x-pack/plugins/monitoring/public/angular/helpers/utils.ts b/x-pack/plugins/monitoring/public/angular/helpers/utils.ts deleted file mode 100644 index 32184ad71ed8d..0000000000000 --- a/x-pack/plugins/monitoring/public/angular/helpers/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { IScope } from 'angular'; -import * as Rx from 'rxjs'; - -/** - * Subscribe to an observable at a $scope, ensuring that the digest cycle - * is run for subscriber hooks and routing errors to fatalError if not handled. - */ -export const subscribeWithScope = ( - $scope: IScope, - observable: Rx.Observable, - observer?: Rx.PartialObserver -) => { - return observable.subscribe({ - next(value) { - if (observer && observer.next) { - $scope.$applyAsync(() => observer.next!(value)); - } - }, - error(error) { - $scope.$applyAsync(() => { - if (observer && observer.error) { - observer.error(error); - } else { - throw new Error( - `Uncaught error in subscribeWithScope(): ${ - error ? error.stack || error.message : error - }` - ); - } - }); - }, - complete() { - if (observer && observer.complete) { - $scope.$applyAsync(() => observer.complete!()); - } - }, - }); -}; diff --git a/x-pack/plugins/monitoring/public/angular/index.ts b/x-pack/plugins/monitoring/public/angular/index.ts deleted file mode 100644 index 1a655fc1ee256..0000000000000 --- a/x-pack/plugins/monitoring/public/angular/index.ts +++ /dev/null @@ -1,83 +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, { IModule } from 'angular'; -import { uiRoutes } from './helpers/routes'; -import { Legacy } from '../legacy_shims'; -import { configureAppAngularModule } from '../angular/top_nav'; -import { localAppModule, appModuleName } from './app_modules'; -import { APP_WRAPPER_CLASS } from '../../../../../src/core/public'; - -import { MonitoringStartPluginDependencies } from '../types'; - -export class AngularApp { - private injector?: angular.auto.IInjectorService; - - constructor(deps: MonitoringStartPluginDependencies) { - const { - core, - element, - data, - navigation, - isCloud, - pluginInitializerContext, - externalConfig, - triggersActionsUi, - usageCollection, - appMountParameters, - } = deps; - const app: IModule = localAppModule(deps); - app.run(($injector: angular.auto.IInjectorService) => { - this.injector = $injector; - Legacy.init( - { - core, - element, - data, - navigation, - isCloud, - pluginInitializerContext, - externalConfig, - triggersActionsUi, - usageCollection, - appMountParameters, - }, - this.injector - ); - }); - - app.config(($routeProvider: unknown) => uiRoutes.addToProvider($routeProvider)); - - const np = { core, env: pluginInitializerContext.env }; - configureAppAngularModule(app, np, true); - const appElement = document.createElement('div'); - appElement.setAttribute('style', 'height: 100%'); - appElement.innerHTML = '
'; - - if (!element.classList.contains(APP_WRAPPER_CLASS)) { - element.classList.add(APP_WRAPPER_CLASS); - } - - angular.bootstrap(appElement, [appModuleName]); - angular.element(element).append(appElement); - } - - public destroy = () => { - if (this.injector) { - this.injector.get('$rootScope').$destroy(); - } - }; - - public applyScope = () => { - if (!this.injector) { - return; - } - - const rootScope = this.injector.get('$rootScope'); - rootScope.$applyAsync(); - }; -} diff --git a/x-pack/plugins/monitoring/public/angular/providers/private.js b/x-pack/plugins/monitoring/public/angular/providers/private.js deleted file mode 100644 index 018e2d7d41840..0000000000000 --- a/x-pack/plugins/monitoring/public/angular/providers/private.js +++ /dev/null @@ -1,193 +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. - */ - -/** - * # `Private()` - * Private module loader, used to merge angular and require js dependency styles - * by allowing a require.js module to export a single provider function that will - * create a value used within an angular application. This provider can declare - * angular dependencies by listing them as arguments, and can be require additional - * Private modules. - * - * ## Define a private module provider: - * ```js - * export default function PingProvider($http) { - * this.ping = function () { - * return $http.head('/health-check'); - * }; - * }; - * ``` - * - * ## Require a private module: - * ```js - * export default function ServerHealthProvider(Private, Promise) { - * let ping = Private(require('ui/ping')); - * return { - * check: Promise.method(function () { - * let attempts = 0; - * return (function attempt() { - * attempts += 1; - * return ping.ping() - * .catch(function (err) { - * if (attempts < 3) return attempt(); - * }) - * }()) - * .then(function () { - * return true; - * }) - * .catch(function () { - * return false; - * }); - * }) - * } - * }; - * ``` - * - * # `Private.stub(provider, newInstance)` - * `Private.stub()` replaces the instance of a module with another value. This is all we have needed until now. - * - * ```js - * beforeEach(inject(function ($injector, Private) { - * Private.stub( - * // since this module just exports a function, we need to change - * // what Private returns in order to modify it's behavior - * require('ui/agg_response/hierarchical/_build_split'), - * sinon.stub().returns(fakeSplit) - * ); - * })); - * ``` - * - * # `Private.swap(oldProvider, newProvider)` - * This new method does an 1-for-1 swap of module providers, unlike `stub()` which replaces a modules instance. - * Pass the module you want to swap out, and the one it should be replaced with, then profit. - * - * Note: even though this example shows `swap()` being called in a config - * function, it can be called from anywhere. It is particularly useful - * in this scenario though. - * - * ```js - * beforeEach(module('kibana', function (PrivateProvider) { - * PrivateProvider.swap( - * function StubbedRedirectProvider($decorate) { - * // $decorate is a function that will instantiate the original module when called - * return sinon.spy($decorate()); - * } - * ); - * })); - * ``` - * - * @param {[type]} prov [description] - */ -import { partial, uniqueId, isObject } from 'lodash'; - -const nextId = partial(uniqueId, 'privateProvider#'); - -function name(fn) { - return fn.name || fn.toString().split('\n').shift(); -} - -export function PrivateProvider() { - const provider = this; - - // one cache/swaps per Provider - const cache = {}; - const swaps = {}; - - // return the uniq id for this function - function identify(fn) { - if (typeof fn !== 'function') { - throw new TypeError('Expected private module "' + fn + '" to be a function'); - } - - if (fn.$$id) return fn.$$id; - else return (fn.$$id = nextId()); - } - - provider.stub = function (fn, instance) { - cache[identify(fn)] = instance; - return instance; - }; - - provider.swap = function (fn, prov) { - const id = identify(fn); - swaps[id] = prov; - }; - - provider.$get = [ - '$injector', - function PrivateFactory($injector) { - // prevent circular deps by tracking where we came from - const privPath = []; - const pathToString = function () { - return privPath.map(name).join(' -> '); - }; - - // call a private provider and return the instance it creates - function instantiate(prov, locals) { - if (~privPath.indexOf(prov)) { - throw new Error( - 'Circular reference to "' + - name(prov) + - '"' + - ' found while resolving private deps: ' + - pathToString() - ); - } - - privPath.push(prov); - - const context = {}; - let instance = $injector.invoke(prov, context, locals); - if (!isObject(instance)) instance = context; - - privPath.pop(); - return instance; - } - - // retrieve an instance from cache or create and store on - function get(id, prov, $delegateId, $delegateProv) { - if (cache[id]) return cache[id]; - - let instance; - - if ($delegateId != null && $delegateProv != null) { - instance = instantiate(prov, { - $decorate: partial(get, $delegateId, $delegateProv), - }); - } else { - instance = instantiate(prov); - } - - return (cache[id] = instance); - } - - // main api, get the appropriate instance for a provider - function Private(prov) { - let id = identify(prov); - let $delegateId; - let $delegateProv; - - if (swaps[id]) { - $delegateId = id; - $delegateProv = prov; - - prov = swaps[$delegateId]; - id = identify(prov); - } - - return get(id, prov, $delegateId, $delegateProv); - } - - Private.stub = provider.stub; - Private.swap = provider.swap; - - return Private; - }, - ]; - - return provider; -} diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx index c2dfe1c0dae7d..2a2de0a716cea 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx @@ -34,12 +34,12 @@ export const LogStashPipelinesPage: React.FC = ({ clusters }) => const { getPaginationTableProps, getPaginationRouteOptions, updateTotalItemCount } = useTable('logstash.pipelines'); - const title = i18n.translate('xpack.monitoring.logstash.overview.title', { - defaultMessage: 'Logstash', + const title = i18n.translate('xpack.monitoring.logstash.pipelines.routeTitle', { + defaultMessage: 'Logstash Pipelines', }); - const pageTitle = i18n.translate('xpack.monitoring.logstash.overview.pageTitle', { - defaultMessage: 'Logstash overview', + const pageTitle = i18n.translate('xpack.monitoring.logstash.pipelines.pageTitle', { + defaultMessage: 'Logstash pipelines', }); const getPageData = useCallback(async () => { diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx index e798e7d74ad38..e767074aea42b 100644 --- a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx @@ -17,7 +17,7 @@ import { CODE_PATH_LICENSE, STANDALONE_CLUSTER_CLUSTER_UUID } from '../../../../ import { Legacy } from '../../../legacy_shims'; import { Enabler } from './enabler'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -import { initSetupModeState } from '../../setup_mode/setup_mode'; +import { initSetupModeState } from '../../../lib/setup_mode'; import { GlobalStateContext } from '../../contexts/global_state_context'; import { useRequestErrorHandler } from '../../hooks/use_request_error_handler'; diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index 23eeb2c034a80..c0030cfcfe55c 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -17,7 +17,7 @@ import { getSetupModeState, isSetupModeFeatureEnabled, updateSetupModeData, -} from '../setup_mode/setup_mode'; +} from '../../lib/setup_mode'; import { SetupModeFeature } from '../../../common/enums'; import { AlertsDropdown } from '../../alerts/alerts_dropdown'; import { ActionMenu } from '../../components/action_menu'; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/index.ts b/x-pack/plugins/monitoring/public/application/setup_mode/index.ts index 1bcdcdef09c28..57d734fc6d056 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/index.ts +++ b/x-pack/plugins/monitoring/public/application/setup_mode/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './setup_mode'; +export * from '../../lib/setup_mode'; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx deleted file mode 100644 index 828d5a2d20ae6..0000000000000 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render } from 'react-dom'; -import { get, includes } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { HttpStart, IHttpFetchError } from 'kibana/public'; -import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; -import { Legacy } from '../../legacy_shims'; -import { SetupModeEnterButton } from '../../components/setup_mode/enter_button'; -import { SetupModeFeature } from '../../../common/enums'; -import { ISetupModeContext } from '../../components/setup_mode/setup_mode_context'; -import { State as GlobalState } from '../contexts/global_state_context'; - -function isOnPage(hash: string) { - return includes(window.location.hash, hash); -} - -let globalState: GlobalState; -let httpService: HttpStart; -let errorHandler: (error: IHttpFetchError) => void; - -interface ISetupModeState { - enabled: boolean; - data: any; - callback?: (() => void) | null; - hideBottomBar: boolean; -} -const setupModeState: ISetupModeState = { - enabled: false, - data: null, - callback: null, - hideBottomBar: false, -}; - -export const getSetupModeState = () => setupModeState; - -export const setNewlyDiscoveredClusterUuid = (clusterUuid: string) => { - globalState.cluster_uuid = clusterUuid; - globalState.save?.(); -}; - -export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid = false) => { - const clusterUuid = globalState.cluster_uuid; - const ccs = globalState.ccs; - - let url = '../api/monitoring/v1/setup/collection'; - if (uuid) { - url += `/node/${uuid}`; - } else if (!fetchWithoutClusterUuid && clusterUuid) { - url += `/cluster/${clusterUuid}`; - } else { - url += '/cluster'; - } - - try { - const response = await httpService.post(url, { - body: JSON.stringify({ - ccs, - }), - }); - return response; - } catch (err) { - errorHandler(err); - throw err; - } -}; - -const notifySetupModeDataChange = () => setupModeState.callback && setupModeState.callback(); - -export const updateSetupModeData = async (uuid?: string, fetchWithoutClusterUuid = false) => { - const data = await fetchCollectionData(uuid, fetchWithoutClusterUuid); - setupModeState.data = data; - const hasPermissions = get(data, '_meta.hasPermissions', false); - if (!hasPermissions) { - let text: string = ''; - if (!hasPermissions) { - text = i18n.translate('xpack.monitoring.setupMode.notAvailablePermissions', { - defaultMessage: 'You do not have the necessary permissions to do this.', - }); - } - - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.setupMode.notAvailableTitle', { - defaultMessage: 'Setup mode is not available', - }), - text, - }); - return toggleSetupMode(false); - } - notifySetupModeDataChange(); - - const clusterUuid = globalState.cluster_uuid; - if (!clusterUuid) { - const liveClusterUuid: string = get(data, '_meta.liveClusterUuid'); - const migratedEsNodes = Object.values(get(data, 'elasticsearch.byUuid', {})).filter( - (node: any) => node.isPartiallyMigrated || node.isFullyMigrated - ); - if (liveClusterUuid && migratedEsNodes.length > 0) { - setNewlyDiscoveredClusterUuid(liveClusterUuid); - } - } -}; - -export const hideBottomBar = () => { - setupModeState.hideBottomBar = true; - notifySetupModeDataChange(); -}; -export const showBottomBar = () => { - setupModeState.hideBottomBar = false; - notifySetupModeDataChange(); -}; - -export const disableElasticsearchInternalCollection = async () => { - const clusterUuid = globalState.cluster_uuid; - const url = `../api/monitoring/v1/setup/collection/${clusterUuid}/disable_internal_collection`; - try { - const response = await httpService.post(url); - return response; - } catch (err) { - errorHandler(err); - throw err; - } -}; - -export const toggleSetupMode = (inSetupMode: boolean) => { - setupModeState.enabled = inSetupMode; - globalState.inSetupMode = inSetupMode; - globalState.save?.(); - setSetupModeMenuItem(); - notifySetupModeDataChange(); - - if (inSetupMode) { - // Intentionally do not await this so we don't block UI operations - updateSetupModeData(); - } -}; - -export const setSetupModeMenuItem = () => { - if (isOnPage('no-data')) { - return; - } - - const enabled = !globalState.inSetupMode; - const I18nContext = Legacy.shims.I18nContext; - - render( - - - - - , - document.getElementById('setupModeNav') - ); -}; - -export const initSetupModeState = async ( - state: GlobalState, - http: HttpStart, - handleErrors: (error: IHttpFetchError) => void, - callback?: () => void -) => { - globalState = state; - httpService = http; - errorHandler = handleErrors; - if (callback) { - setupModeState.callback = callback; - } - - if (globalState.inSetupMode) { - toggleSetupMode(true); - } -}; - -export const isInSetupMode = (context?: ISetupModeContext, gState: GlobalState = globalState) => { - if (context?.setupModeSupported === false) { - return false; - } - if (setupModeState.enabled) { - return true; - } - - return gState.inSetupMode; -}; - -export const isSetupModeFeatureEnabled = (feature: SetupModeFeature) => { - if (!setupModeState.enabled) { - return false; - } - - if (feature === SetupModeFeature.MetricbeatMigration) { - if (Legacy.shims.isCloud) { - return false; - } - } - - return true; -}; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js index a9ee2464cd423..df524fa99ae53 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js @@ -13,7 +13,7 @@ import { disableElasticsearchInternalCollection, toggleSetupMode, setSetupModeMenuItem, -} from './setup_mode'; +} from '../../lib/setup_mode'; import { Flyout } from '../../components/metricbeat_migration/flyout'; import { EuiBottomBar, diff --git a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js b/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js deleted file mode 100644 index 69579cb831c06..0000000000000 --- a/x-pack/plugins/monitoring/public/directives/elasticsearch/ml_job_listing/index.js +++ /dev/null @@ -1,171 +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 { capitalize } from 'lodash'; -import numeral from '@elastic/numeral'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { EuiMonitoringTable } from '../../../components/table'; -import { MachineLearningJobStatusIcon } from '../../../components/elasticsearch/ml_job_listing/status_icon'; -import { LARGE_ABBREVIATED, LARGE_BYTES } from '../../../../common/formatting'; -import { EuiLink, EuiPage, EuiPageContent, EuiPageBody, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { ClusterStatus } from '../../../components/elasticsearch/cluster_status'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; - -const getColumns = () => [ - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.jobIdTitle', { - defaultMessage: 'Job ID', - }), - field: 'job_id', - sortable: true, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.stateTitle', { - defaultMessage: 'State', - }), - field: 'state', - sortable: true, - render: (state) => ( -
- -   - {capitalize(state)} -
- ), - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.processedRecordsTitle', { - defaultMessage: 'Processed Records', - }), - field: 'data_counts.processed_record_count', - sortable: true, - render: (value) => {numeral(value).format(LARGE_ABBREVIATED)}, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.modelSizeTitle', { - defaultMessage: 'Model Size', - }), - field: 'model_size_stats.model_bytes', - sortable: true, - render: (value) => {numeral(value).format(LARGE_BYTES)}, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.forecastsTitle', { - defaultMessage: 'Forecasts', - }), - field: 'forecasts_stats.total', - sortable: true, - render: (value) => {numeral(value).format(LARGE_ABBREVIATED)}, - }, - { - name: i18n.translate('xpack.monitoring.elasticsearch.mlJobListing.nodeTitle', { - defaultMessage: 'Node', - }), - field: 'node.name', - sortable: true, - render: (name, node) => { - if (node) { - return ( - - {name} - - ); - } - - return ( - - ); - }, - }, -]; - -//monitoringMlListing -export function monitoringMlListingProvider() { - return { - restrict: 'E', - scope: { - jobs: '=', - paginationSettings: '=', - sorting: '=', - onTableChange: '=', - status: '=', - }, - link(scope, $el) { - scope.$on('$destroy', () => $el && $el[0] && unmountComponentAtNode($el[0])); - const columns = getColumns(); - - const filterJobsPlaceholder = i18n.translate( - 'xpack.monitoring.elasticsearch.mlJobListing.filterJobsPlaceholder', - { - defaultMessage: 'Filter Jobs…', - } - ); - - scope.$watch('jobs', (_jobs = []) => { - const jobs = _jobs.map((job) => { - if (job.ml) { - return { - ...job.ml.job, - node: job.node, - job_id: job.ml.job.id, - }; - } - return job; - }); - const mlTable = ( - - - - - - - - - - - - ); - render(mlTable, $el[0]); - }); - }, - }; -} diff --git a/x-pack/plugins/monitoring/public/directives/main/index.html b/x-pack/plugins/monitoring/public/directives/main/index.html deleted file mode 100644 index fd14120e1db2f..0000000000000 --- a/x-pack/plugins/monitoring/public/directives/main/index.html +++ /dev/null @@ -1,323 +0,0 @@ -
-
-
-
-
-
-
-

{{pageTitle || monitoringMain.instance}}

-
-
-
-
-
- - -
-
-
- - - - - - - - - - - - - - - -
-
-
diff --git a/x-pack/plugins/monitoring/public/directives/main/index.js b/x-pack/plugins/monitoring/public/directives/main/index.js deleted file mode 100644 index 0e464f0a356c4..0000000000000 --- a/x-pack/plugins/monitoring/public/directives/main/index.js +++ /dev/null @@ -1,275 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { EuiSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import template from './index.html'; -import { Legacy } from '../../legacy_shims'; -import { shortenPipelineHash } from '../../../common/formatting'; -import { - getSetupModeState, - initSetupModeState, - isSetupModeFeatureEnabled, -} from '../../lib/setup_mode'; -import { Subscription } from 'rxjs'; -import { getSafeForExternalLink } from '../../lib/get_safe_for_external_link'; -import { SetupModeFeature } from '../../../common/enums'; -import './index.scss'; - -const setOptions = (controller) => { - if ( - !controller.pipelineVersions || - !controller.pipelineVersions.length || - !controller.pipelineDropdownElement - ) { - return; - } - - render( - - - { - return { - text: i18n.translate( - 'xpack.monitoring.logstashNavigation.pipelineVersionDescription', - { - defaultMessage: - 'Version active {relativeLastSeen} and first seen {relativeFirstSeen}', - values: { - relativeLastSeen: option.relativeLastSeen, - relativeFirstSeen: option.relativeFirstSeen, - }, - } - ), - value: option.hash, - }; - })} - onChange={controller.onChangePipelineHash} - /> - - , - controller.pipelineDropdownElement - ); -}; - -/* - * Manage data and provide helper methods for the "main" directive's template - */ -export class MonitoringMainController { - // called internally by Angular - constructor() { - this.inListing = false; - this.inAlerts = false; - this.inOverview = false; - this.inElasticsearch = false; - this.inKibana = false; - this.inLogstash = false; - this.inBeats = false; - this.inApm = false; - } - - addTimerangeObservers = () => { - const timefilter = Legacy.shims.timefilter; - this.subscriptions = new Subscription(); - - const refreshIntervalUpdated = () => { - const { value: refreshInterval, pause: isPaused } = timefilter.getRefreshInterval(); - this.datePicker.onRefreshChange({ refreshInterval, isPaused }, true); - }; - - const timeUpdated = () => { - this.datePicker.onTimeUpdate({ dateRange: timefilter.getTime() }, true); - }; - - this.subscriptions.add( - timefilter.getRefreshIntervalUpdate$().subscribe(refreshIntervalUpdated) - ); - this.subscriptions.add(timefilter.getTimeUpdate$().subscribe(timeUpdated)); - }; - - dropdownLoadedHandler() { - this.pipelineDropdownElement = document.querySelector('#dropdown-elm'); - setOptions(this); - } - - // kick things off from the directive link function - setup(options) { - const timefilter = Legacy.shims.timefilter; - this._licenseService = options.licenseService; - this._breadcrumbsService = options.breadcrumbsService; - this._executorService = options.executorService; - - Object.assign(this, options.attributes); - - this.navName = `${this.name}-nav`; - - // set the section we're navigated in - if (this.product) { - this.inElasticsearch = this.product === 'elasticsearch'; - this.inKibana = this.product === 'kibana'; - this.inLogstash = this.product === 'logstash'; - this.inBeats = this.product === 'beats'; - this.inApm = this.product === 'apm'; - } else { - this.inOverview = this.name === 'overview'; - this.inAlerts = this.name === 'alerts'; - this.inListing = this.name === 'listing'; // || this.name === 'no-data'; - } - - if (!this.inListing) { - // no breadcrumbs in cluster listing page - this.breadcrumbs = this._breadcrumbsService(options.clusterName, this); - } - - if (this.pipelineHash) { - this.pipelineHashShort = shortenPipelineHash(this.pipelineHash); - this.onChangePipelineHash = () => { - window.location.hash = getSafeForExternalLink( - `#/logstash/pipelines/${this.pipelineId}/${this.pipelineHash}` - ); - }; - } - - this.datePicker = { - enableTimeFilter: timefilter.isTimeRangeSelectorEnabled(), - timeRange: timefilter.getTime(), - refreshInterval: timefilter.getRefreshInterval(), - onRefreshChange: ({ isPaused, refreshInterval }, skipSet = false) => { - this.datePicker.refreshInterval = { - pause: isPaused, - value: refreshInterval, - }; - if (!skipSet) { - timefilter.setRefreshInterval({ - pause: isPaused, - value: refreshInterval ? refreshInterval : this.datePicker.refreshInterval.value, - }); - } - }, - onTimeUpdate: ({ dateRange }, skipSet = false) => { - this.datePicker.timeRange = { - ...dateRange, - }; - if (!skipSet) { - timefilter.setTime(dateRange); - } - this._executorService.cancel(); - this._executorService.run(); - }, - }; - } - - // check whether to "highlight" a tab - isActiveTab(testPath) { - return this.name === testPath; - } - - // check whether to show ML tab - isMlSupported() { - return this._licenseService.mlIsSupported(); - } - - isDisabledTab(product) { - const setupMode = getSetupModeState(); - if (!isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { - return false; - } - - if (!setupMode.data) { - return false; - } - - const data = setupMode.data[product] || {}; - if (data.totalUniqueInstanceCount === 0) { - return true; - } - if ( - data.totalUniqueInternallyCollectedCount === 0 && - data.totalUniqueFullyMigratedCount === 0 && - data.totalUniquePartiallyMigratedCount === 0 - ) { - return true; - } - return false; - } -} - -export function monitoringMainProvider(breadcrumbs, license, $injector) { - const $executor = $injector.get('$executor'); - const $parse = $injector.get('$parse'); - - return { - restrict: 'E', - transclude: true, - template, - controller: MonitoringMainController, - controllerAs: 'monitoringMain', - bindToController: true, - link(scope, _element, attributes, controller) { - scope.$applyAsync(() => { - controller.addTimerangeObservers(); - const setupObj = getSetupObj(); - controller.setup(setupObj); - Object.keys(setupObj.attributes).forEach((key) => { - attributes.$observe(key, () => controller.setup(getSetupObj())); - }); - if (attributes.onLoaded) { - const onLoaded = $parse(attributes.onLoaded)(scope); - onLoaded(); - } - }); - - initSetupModeState(scope, $injector, () => { - controller.setup(getSetupObj()); - }); - if (!scope.cluster) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - scope.cluster = ($route.current.locals.clusters || []).find( - (cluster) => cluster.cluster_uuid === globalState.cluster_uuid - ); - } - - function getSetupObj() { - return { - licenseService: license, - breadcrumbsService: breadcrumbs, - executorService: $executor, - attributes: { - name: attributes.name, - product: attributes.product, - instance: attributes.instance, - resolver: attributes.resolver, - page: attributes.page, - tabIconClass: attributes.tabIconClass, - tabIconLabel: attributes.tabIconLabel, - pipelineId: attributes.pipelineId, - pipelineHash: attributes.pipelineHash, - pipelineVersions: get(scope, 'pageData.versions'), - isCcrEnabled: attributes.isCcrEnabled === 'true' || attributes.isCcrEnabled === true, - }, - clusterName: get(scope, 'cluster.cluster_name'), - }; - } - - scope.$on('$destroy', () => { - controller.pipelineDropdownElement && - unmountComponentAtNode(controller.pipelineDropdownElement); - controller.subscriptions && controller.subscriptions.unsubscribe(); - }); - scope.$watch('pageData.versions', (versions) => { - controller.pipelineVersions = versions; - setOptions(controller); - }); - }, - }; -} diff --git a/x-pack/plugins/monitoring/public/directives/main/index.scss b/x-pack/plugins/monitoring/public/directives/main/index.scss deleted file mode 100644 index db5d2b72ab07b..0000000000000 --- a/x-pack/plugins/monitoring/public/directives/main/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -.monTopNavSecondItem { - padding-left: $euiSizeM; -} diff --git a/x-pack/plugins/monitoring/public/directives/main/monitoring_main_controller.test.js b/x-pack/plugins/monitoring/public/directives/main/monitoring_main_controller.test.js deleted file mode 100644 index 195e11cee6112..0000000000000 --- a/x-pack/plugins/monitoring/public/directives/main/monitoring_main_controller.test.js +++ /dev/null @@ -1,286 +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 { noop } from 'lodash'; -import expect from '@kbn/expect'; -import { Legacy } from '../../legacy_shims'; -import { MonitoringMainController } from './'; - -const getMockLicenseService = (options) => ({ mlIsSupported: () => options.mlIsSupported }); -const getMockBreadcrumbsService = () => noop; // breadcrumb service has its own test - -describe('Monitoring Main Directive Controller', () => { - const core = { - notifications: {}, - application: {}, - i18n: {}, - chrome: {}, - }; - const data = { - query: { - timefilter: { - timefilter: { - isTimeRangeSelectorEnabled: () => true, - getTime: () => 1, - getRefreshInterval: () => 1, - }, - }, - }, - }; - const isCloud = false; - const triggersActionsUi = {}; - - beforeAll(() => { - Legacy.init({ - core, - data, - isCloud, - triggersActionsUi, - }); - }); - - /* - * Simulates calling the monitoringMain directive the way Cluster Listing - * does: - * - * ... - */ - it('in Cluster Listing', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - name: 'listing', - }, - }); - - // derived properties - expect(controller.inListing).to.be(true); - expect(controller.inAlerts).to.be(false); - expect(controller.inOverview).to.be(false); - - // attributes - expect(controller.name).to.be('listing'); - expect(controller.product).to.be(undefined); - expect(controller.instance).to.be(undefined); - expect(controller.resolver).to.be(undefined); - expect(controller.page).to.be(undefined); - expect(controller.tabIconClass).to.be(undefined); - expect(controller.tabIconLabel).to.be(undefined); - }); - - /* - * Simulates calling the monitoringMain directive the way Cluster Alerts - * Listing does: - * - * ... - */ - it('in Cluster Alerts', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - name: 'alerts', - }, - }); - - // derived properties - expect(controller.inListing).to.be(false); - expect(controller.inAlerts).to.be(true); - expect(controller.inOverview).to.be(false); - - // attributes - expect(controller.name).to.be('alerts'); - expect(controller.product).to.be(undefined); - expect(controller.instance).to.be(undefined); - expect(controller.resolver).to.be(undefined); - expect(controller.page).to.be(undefined); - expect(controller.tabIconClass).to.be(undefined); - expect(controller.tabIconLabel).to.be(undefined); - }); - - /* - * Simulates calling the monitoringMain directive the way Cluster Overview - * does: - * - * ... - */ - it('in Cluster Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - name: 'overview', - }, - }); - - // derived properties - expect(controller.inListing).to.be(false); - expect(controller.inAlerts).to.be(false); - expect(controller.inOverview).to.be(true); - - // attributes - expect(controller.name).to.be('overview'); - expect(controller.product).to.be(undefined); - expect(controller.instance).to.be(undefined); - expect(controller.resolver).to.be(undefined); - expect(controller.page).to.be(undefined); - expect(controller.tabIconClass).to.be(undefined); - expect(controller.tabIconLabel).to.be(undefined); - }); - - /* - * Simulates calling the monitoringMain directive the way that Elasticsearch - * Node / Advanced does: - * - * ... - */ - it('in ES Node - Advanced', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - product: 'elasticsearch', - name: 'nodes', - instance: 'es-node-name-01', - resolver: 'es-node-resolver-01', - page: 'advanced', - tabIconClass: 'fa star', - tabIconLabel: 'Master Node', - }, - }); - - // derived properties - expect(controller.inListing).to.be(false); - expect(controller.inAlerts).to.be(false); - expect(controller.inOverview).to.be(false); - - // attributes - expect(controller.name).to.be('nodes'); - expect(controller.product).to.be('elasticsearch'); - expect(controller.instance).to.be('es-node-name-01'); - expect(controller.resolver).to.be('es-node-resolver-01'); - expect(controller.page).to.be('advanced'); - expect(controller.tabIconClass).to.be('fa star'); - expect(controller.tabIconLabel).to.be('Master Node'); - }); - - /** - * - */ - it('in Kibana Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - product: 'kibana', - name: 'overview', - }, - }); - - // derived properties - expect(controller.inListing).to.be(false); - expect(controller.inAlerts).to.be(false); - expect(controller.inOverview).to.be(false); - - // attributes - expect(controller.name).to.be('overview'); - expect(controller.product).to.be('kibana'); - expect(controller.instance).to.be(undefined); - expect(controller.resolver).to.be(undefined); - expect(controller.page).to.be(undefined); - expect(controller.tabIconClass).to.be(undefined); - expect(controller.tabIconLabel).to.be(undefined); - }); - - /** - * - */ - it('in Logstash Listing', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService(), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - product: 'logstash', - name: 'listing', - }, - }); - - // derived properties - expect(controller.inListing).to.be(false); - expect(controller.inAlerts).to.be(false); - expect(controller.inOverview).to.be(false); - - // attributes - expect(controller.name).to.be('listing'); - expect(controller.product).to.be('logstash'); - expect(controller.instance).to.be(undefined); - expect(controller.resolver).to.be(undefined); - expect(controller.page).to.be(undefined); - expect(controller.tabIconClass).to.be(undefined); - expect(controller.tabIconLabel).to.be(undefined); - }); - - /* - * Test `controller.isMlSupported` function - */ - describe('Checking support for ML', () => { - it('license supports ML', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService({ mlIsSupported: true }), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - name: 'listing', - }, - }); - - expect(controller.isMlSupported()).to.be(true); - }); - it('license does not support ML', () => { - getMockLicenseService({ mlIsSupported: false }); - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: getMockLicenseService({ mlIsSupported: false }), - breadcrumbsService: getMockBreadcrumbsService(), - attributes: { - name: 'listing', - }, - }); - - expect(controller.isMlSupported()).to.be(false); - }); - }); -}); diff --git a/x-pack/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/plugins/monitoring/public/lib/setup_mode.test.js index 6dad6effeecc1..47cae9c4f0851 100644 --- a/x-pack/plugins/monitoring/public/lib/setup_mode.test.js +++ b/x-pack/plugins/monitoring/public/lib/setup_mode.test.js @@ -83,7 +83,7 @@ function waitForSetupModeData() { return new Promise((resolve) => process.nextTick(resolve)); } -describe('setup_mode', () => { +xdescribe('setup_mode', () => { beforeEach(async () => { setModulesAndMocks(); }); diff --git a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx index fca7f94731bc5..e582f4aa40812 100644 --- a/x-pack/plugins/monitoring/public/lib/setup_mode.tsx +++ b/x-pack/plugins/monitoring/public/lib/setup_mode.tsx @@ -9,37 +9,21 @@ import React from 'react'; import { render } from 'react-dom'; import { get, includes } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { HttpStart, IHttpFetchError } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; import { Legacy } from '../legacy_shims'; -import { ajaxErrorHandlersProvider } from './ajax_error_handler'; import { SetupModeEnterButton } from '../components/setup_mode/enter_button'; import { SetupModeFeature } from '../../common/enums'; import { ISetupModeContext } from '../components/setup_mode/setup_mode_context'; -import * as setupModeReact from '../application/setup_mode/setup_mode'; -import { isReactMigrationEnabled } from '../external_config'; +import { State as GlobalState } from '../application/contexts/global_state_context'; function isOnPage(hash: string) { return includes(window.location.hash, hash); } -interface IAngularState { - injector: any; - scope: any; -} - -const angularState: IAngularState = { - injector: null, - scope: null, -}; - -const checkAngularState = () => { - if (!angularState.injector || !angularState.scope) { - throw new Error( - 'Unable to interact with setup mode because the angular injector was not previously set.' + - ' This needs to be set by calling `initSetupModeState`.' - ); - } -}; +let globalState: GlobalState; +let httpService: HttpStart; +let errorHandler: (error: IHttpFetchError) => void; interface ISetupModeState { enabled: boolean; @@ -57,20 +41,11 @@ const setupModeState: ISetupModeState = { export const getSetupModeState = () => setupModeState; export const setNewlyDiscoveredClusterUuid = (clusterUuid: string) => { - const globalState = angularState.injector.get('globalState'); - const executor = angularState.injector.get('$executor'); - angularState.scope.$apply(() => { - globalState.cluster_uuid = clusterUuid; - globalState.save(); - }); - executor.run(); + globalState.cluster_uuid = clusterUuid; + globalState.save?.(); }; export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid = false) => { - checkAngularState(); - - const http = angularState.injector.get('$http'); - const globalState = angularState.injector.get('globalState'); const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; @@ -84,12 +59,15 @@ export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid } try { - const response = await http.post(url, { ccs }); - return response.data; + const response = await httpService.post(url, { + body: JSON.stringify({ + ccs, + }), + }); + return response; } catch (err) { - const Private = angularState.injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); + errorHandler(err); + throw err; } }; @@ -107,19 +85,16 @@ export const updateSetupModeData = async (uuid?: string, fetchWithoutClusterUuid }); } - angularState.scope.$evalAsync(() => { - Legacy.shims.toastNotifications.addDanger({ - title: i18n.translate('xpack.monitoring.setupMode.notAvailableTitle', { - defaultMessage: 'Setup mode is not available', - }), - text, - }); + Legacy.shims.toastNotifications.addDanger({ + title: i18n.translate('xpack.monitoring.setupMode.notAvailableTitle', { + defaultMessage: 'Setup mode is not available', + }), + text, }); return toggleSetupMode(false); } notifySetupModeDataChange(); - const globalState = angularState.injector.get('globalState'); const clusterUuid = globalState.cluster_uuid; if (!clusterUuid) { const liveClusterUuid: string = get(data, '_meta.liveClusterUuid'); @@ -142,31 +117,21 @@ export const showBottomBar = () => { }; export const disableElasticsearchInternalCollection = async () => { - checkAngularState(); - - const http = angularState.injector.get('$http'); - const globalState = angularState.injector.get('globalState'); const clusterUuid = globalState.cluster_uuid; const url = `../api/monitoring/v1/setup/collection/${clusterUuid}/disable_internal_collection`; try { - const response = await http.post(url); - return response.data; + const response = await httpService.post(url); + return response; } catch (err) { - const Private = angularState.injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); + errorHandler(err); + throw err; } }; export const toggleSetupMode = (inSetupMode: boolean) => { - if (isReactMigrationEnabled()) return setupModeReact.toggleSetupMode(inSetupMode); - - checkAngularState(); - - const globalState = angularState.injector.get('globalState'); setupModeState.enabled = inSetupMode; globalState.inSetupMode = inSetupMode; - globalState.save(); + globalState.save?.(); setSetupModeMenuItem(); notifySetupModeDataChange(); @@ -177,13 +142,10 @@ export const toggleSetupMode = (inSetupMode: boolean) => { }; export const setSetupModeMenuItem = () => { - checkAngularState(); - if (isOnPage('no-data')) { return; } - const globalState = angularState.injector.get('globalState'); const enabled = !globalState.inSetupMode; const I18nContext = Legacy.shims.I18nContext; @@ -197,23 +159,25 @@ export const setSetupModeMenuItem = () => { ); }; -export const addSetupModeCallback = (callback: () => void) => (setupModeState.callback = callback); - -export const initSetupModeState = async ($scope: any, $injector: any, callback?: () => void) => { - angularState.scope = $scope; - angularState.injector = $injector; +export const initSetupModeState = async ( + state: GlobalState, + http: HttpStart, + handleErrors: (error: IHttpFetchError) => void, + callback?: () => void +) => { + globalState = state; + httpService = http; + errorHandler = handleErrors; if (callback) { setupModeState.callback = callback; } - const globalState = $injector.get('globalState'); if (globalState.inSetupMode) { toggleSetupMode(true); } }; -export const isInSetupMode = (context?: ISetupModeContext) => { - if (isReactMigrationEnabled()) return setupModeReact.isInSetupMode(context); +export const isInSetupMode = (context?: ISetupModeContext, gState: GlobalState = globalState) => { if (context?.setupModeSupported === false) { return false; } @@ -221,20 +185,19 @@ export const isInSetupMode = (context?: ISetupModeContext) => { return true; } - const $injector = angularState.injector || Legacy.shims.getAngularInjector(); - const globalState = $injector.get('globalState'); - return globalState.inSetupMode; + return gState.inSetupMode; }; export const isSetupModeFeatureEnabled = (feature: SetupModeFeature) => { - if (isReactMigrationEnabled()) return setupModeReact.isSetupModeFeatureEnabled(feature); if (!setupModeState.enabled) { return false; } + if (feature === SetupModeFeature.MetricbeatMigration) { if (Legacy.shims.isCloud) { return false; } } + return true; }; diff --git a/x-pack/plugins/monitoring/public/plugin.ts b/x-pack/plugins/monitoring/public/plugin.ts index 0792d083b3da5..dc8f68aae416d 100644 --- a/x-pack/plugins/monitoring/public/plugin.ts +++ b/x-pack/plugins/monitoring/public/plugin.ts @@ -36,9 +36,6 @@ interface MonitoringSetupPluginDependencies { triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; } - -const HASH_CHANGE = 'hashchange'; - export class MonitoringPlugin implements Plugin @@ -88,7 +85,6 @@ export class MonitoringPlugin category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); - const { AngularApp } = await import('./angular'); const externalConfig = this.getExternalConfig(); const deps: MonitoringStartPluginDependencies = { navigation: pluginsStart.navigation, @@ -118,26 +114,8 @@ export class MonitoringPlugin const config = Object.fromEntries(externalConfig); setConfig(config); - if (config.renderReactApp) { - const { renderApp } = await import('./application'); - return renderApp(coreStart, pluginsStart, params, config); - } else { - const monitoringApp = new AngularApp(deps); - const removeHistoryListener = params.history.listen((location) => { - if (location.pathname === '' && location.hash === '') { - monitoringApp.applyScope(); - } - }); - - const removeHashChange = this.setInitialTimefilter(deps); - return () => { - if (removeHashChange) { - removeHashChange(); - } - removeHistoryListener(); - monitoringApp.destroy(); - }; - } + const { renderApp } = await import('./application'); + return renderApp(coreStart, pluginsStart, params, config); }, }; @@ -148,28 +126,6 @@ export class MonitoringPlugin public stop() {} - private setInitialTimefilter({ data }: MonitoringStartPluginDependencies) { - const { timefilter } = data.query.timefilter; - const { pause: pauseByDefault } = timefilter.getRefreshIntervalDefaults(); - if (pauseByDefault) { - return; - } - /** - * We can't use timefilter.getRefreshIntervalUpdate$ last value, - * since it's not a BehaviorSubject. This means we need to wait for - * hash change because of angular's applyAsync - */ - const onHashChange = () => { - const { value, pause } = timefilter.getRefreshInterval(); - if (!value && pause) { - window.removeEventListener(HASH_CHANGE, onHashChange); - timefilter.setRefreshInterval({ value: 10000, pause: false }); - } - }; - window.addEventListener(HASH_CHANGE, onHashChange, false); - return () => window.removeEventListener(HASH_CHANGE, onHashChange); - } - private getExternalConfig() { const monitoring = this.initializerContext.config.get(); return [ diff --git a/x-pack/plugins/monitoring/public/services/breadcrumbs.js b/x-pack/plugins/monitoring/public/services/breadcrumbs.js deleted file mode 100644 index 54ff46f4bf0ab..0000000000000 --- a/x-pack/plugins/monitoring/public/services/breadcrumbs.js +++ /dev/null @@ -1,214 +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 { Legacy } from '../legacy_shims'; -import { i18n } from '@kbn/i18n'; - -// Helper for making objects to use in a link element -const createCrumb = (url, label, testSubj, ignoreGlobalState = false) => { - const crumb = { url, label, ignoreGlobalState }; - if (testSubj) { - crumb.testSubj = testSubj; - } - return crumb; -}; - -// generate Elasticsearch breadcrumbs -function getElasticsearchBreadcrumbs(mainInstance) { - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/elasticsearch', 'Elasticsearch')); - if (mainInstance.name === 'indices') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/indices', - i18n.translate('xpack.monitoring.breadcrumbs.es.indicesLabel', { - defaultMessage: 'Indices', - }), - 'breadcrumbEsIndices' - ) - ); - } else if (mainInstance.name === 'nodes') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/nodes', - i18n.translate('xpack.monitoring.breadcrumbs.es.nodesLabel', { defaultMessage: 'Nodes' }), - 'breadcrumbEsNodes' - ) - ); - } else if (mainInstance.name === 'ml') { - // ML Instance (for user later) - breadcrumbs.push( - createCrumb( - '#/elasticsearch/ml_jobs', - i18n.translate('xpack.monitoring.breadcrumbs.es.jobsLabel', { - defaultMessage: 'Machine learning jobs', - }) - ) - ); - } else if (mainInstance.name === 'ccr_shard') { - breadcrumbs.push( - createCrumb( - '#/elasticsearch/ccr', - i18n.translate('xpack.monitoring.breadcrumbs.es.ccrLabel', { defaultMessage: 'CCR' }) - ) - ); - } - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, 'Elasticsearch')); - } - return breadcrumbs; -} - -// generate Kibana breadcrumbs -function getKibanaBreadcrumbs(mainInstance) { - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/kibana', 'Kibana')); - breadcrumbs.push( - createCrumb( - '#/kibana/instances', - i18n.translate('xpack.monitoring.breadcrumbs.kibana.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, 'Kibana')); - } - return breadcrumbs; -} - -// generate Logstash breadcrumbs -function getLogstashBreadcrumbs(mainInstance) { - const logstashLabel = i18n.translate('xpack.monitoring.breadcrumbs.logstashLabel', { - defaultMessage: 'Logstash', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/logstash', logstashLabel)); - if (mainInstance.name === 'nodes') { - breadcrumbs.push( - createCrumb( - '#/logstash/nodes', - i18n.translate('xpack.monitoring.breadcrumbs.logstash.nodesLabel', { - defaultMessage: 'Nodes', - }) - ) - ); - } - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else if (mainInstance.page === 'pipeline') { - breadcrumbs.push(createCrumb('#/logstash', logstashLabel)); - breadcrumbs.push( - createCrumb( - '#/logstash/pipelines', - i18n.translate('xpack.monitoring.breadcrumbs.logstash.pipelinesLabel', { - defaultMessage: 'Pipelines', - }) - ) - ); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, logstashLabel)); - } - - return breadcrumbs; -} - -// generate Beats breadcrumbs -function getBeatsBreadcrumbs(mainInstance) { - const beatsLabel = i18n.translate('xpack.monitoring.breadcrumbs.beatsLabel', { - defaultMessage: 'Beats', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/beats', beatsLabel)); - breadcrumbs.push( - createCrumb( - '#/beats/beats', - i18n.translate('xpack.monitoring.breadcrumbs.beats.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - breadcrumbs.push(createCrumb(null, beatsLabel)); - } - - return breadcrumbs; -} - -// generate Apm breadcrumbs -function getApmBreadcrumbs(mainInstance) { - const apmLabel = i18n.translate('xpack.monitoring.breadcrumbs.apmLabel', { - defaultMessage: 'APM server', - }); - const breadcrumbs = []; - if (mainInstance.instance) { - breadcrumbs.push(createCrumb('#/apm', apmLabel)); - breadcrumbs.push( - createCrumb( - '#/apm/instances', - i18n.translate('xpack.monitoring.breadcrumbs.apm.instancesLabel', { - defaultMessage: 'Instances', - }) - ) - ); - breadcrumbs.push(createCrumb(null, mainInstance.instance)); - } else { - // don't link to Overview when we're possibly on Overview or its sibling tabs - breadcrumbs.push(createCrumb(null, apmLabel)); - } - return breadcrumbs; -} - -export function breadcrumbsProvider() { - return function createBreadcrumbs(clusterName, mainInstance) { - const homeCrumb = i18n.translate('xpack.monitoring.breadcrumbs.clustersLabel', { - defaultMessage: 'Clusters', - }); - - let breadcrumbs = [createCrumb('#/home', homeCrumb, 'breadcrumbClusters', true)]; - - if (!mainInstance.inOverview && clusterName) { - breadcrumbs.push(createCrumb('#/overview', clusterName)); - } - - if (mainInstance.inElasticsearch) { - breadcrumbs = breadcrumbs.concat(getElasticsearchBreadcrumbs(mainInstance)); - } - if (mainInstance.inKibana) { - breadcrumbs = breadcrumbs.concat(getKibanaBreadcrumbs(mainInstance)); - } - if (mainInstance.inLogstash) { - breadcrumbs = breadcrumbs.concat(getLogstashBreadcrumbs(mainInstance)); - } - if (mainInstance.inBeats) { - breadcrumbs = breadcrumbs.concat(getBeatsBreadcrumbs(mainInstance)); - } - if (mainInstance.inApm) { - breadcrumbs = breadcrumbs.concat(getApmBreadcrumbs(mainInstance)); - } - - Legacy.shims.breadcrumbs.set( - breadcrumbs.map((b) => ({ - text: b.label, - href: b.url, - 'data-test-subj': b.testSubj, - ignoreGlobalState: b.ignoreGlobalState, - })) - ); - - return breadcrumbs; - }; -} diff --git a/x-pack/plugins/monitoring/public/services/breadcrumbs.test.js b/x-pack/plugins/monitoring/public/services/breadcrumbs.test.js deleted file mode 100644 index 0af5d59e54555..0000000000000 --- a/x-pack/plugins/monitoring/public/services/breadcrumbs.test.js +++ /dev/null @@ -1,166 +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 expect from '@kbn/expect'; -import { breadcrumbsProvider } from './breadcrumbs'; -import { MonitoringMainController } from '../directives/main'; -import { Legacy } from '../legacy_shims'; - -describe('Monitoring Breadcrumbs Service', () => { - const core = { - notifications: {}, - application: {}, - i18n: {}, - chrome: {}, - }; - const data = { - query: { - timefilter: { - timefilter: { - isTimeRangeSelectorEnabled: () => true, - getTime: () => 1, - getRefreshInterval: () => 1, - }, - }, - }, - }; - const isCloud = false; - const triggersActionsUi = {}; - - beforeAll(() => { - Legacy.init({ - core, - data, - isCloud, - triggersActionsUi, - }); - }); - - it('in Cluster Alerts', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - name: 'alerts', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - { url: '#/overview', label: 'test-cluster-foo', ignoreGlobalState: false }, - ]); - }); - - it('in Cluster Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - name: 'overview', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - ]); - }); - - it('in ES Node - Advanced', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'elasticsearch', - name: 'nodes', - instance: 'es-node-name-01', - resolver: 'es-node-resolver-01', - page: 'advanced', - tabIconClass: 'fa star', - tabIconLabel: 'Master Node', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - { url: '#/overview', label: 'test-cluster-foo', ignoreGlobalState: false }, - { url: '#/elasticsearch', label: 'Elasticsearch', ignoreGlobalState: false }, - { - url: '#/elasticsearch/nodes', - label: 'Nodes', - testSubj: 'breadcrumbEsNodes', - ignoreGlobalState: false, - }, - { url: null, label: 'es-node-name-01', ignoreGlobalState: false }, - ]); - }); - - it('in Kibana Overview', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'kibana', - name: 'overview', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - { url: '#/overview', label: 'test-cluster-foo', ignoreGlobalState: false }, - { url: null, label: 'Kibana', ignoreGlobalState: false }, - ]); - }); - - /** - * - */ - it('in Logstash Listing', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'logstash', - name: 'listing', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - { url: '#/overview', label: 'test-cluster-foo', ignoreGlobalState: false }, - { url: null, label: 'Logstash', ignoreGlobalState: false }, - ]); - }); - - /** - * - */ - it('in Logstash Pipeline Viewer', () => { - const controller = new MonitoringMainController(); - controller.setup({ - clusterName: 'test-cluster-foo', - licenseService: {}, - breadcrumbsService: breadcrumbsProvider(), - attributes: { - product: 'logstash', - page: 'pipeline', - pipelineId: 'main', - pipelineHash: '42ee890af9...', - }, - }); - expect(controller.breadcrumbs).to.eql([ - { url: '#/home', label: 'Clusters', testSubj: 'breadcrumbClusters', ignoreGlobalState: true }, - { url: '#/overview', label: 'test-cluster-foo', ignoreGlobalState: false }, - { url: '#/logstash', label: 'Logstash', ignoreGlobalState: false }, - { url: '#/logstash/pipelines', label: 'Pipelines', ignoreGlobalState: false }, - ]); - }); -}); diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js deleted file mode 100644 index b19d0ea56765f..0000000000000 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ /dev/null @@ -1,59 +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 { ajaxErrorHandlersProvider } from '../lib/ajax_error_handler'; -import { Legacy } from '../legacy_shims'; -import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../common/constants'; - -function formatClusters(clusters) { - return clusters.map(formatCluster); -} - -function formatCluster(cluster) { - if (cluster.cluster_uuid === STANDALONE_CLUSTER_CLUSTER_UUID) { - cluster.cluster_name = 'Standalone Cluster'; - } - return cluster; -} - -export function monitoringClustersProvider($injector) { - return async (clusterUuid, ccs, codePaths) => { - const { min, max } = Legacy.shims.timefilter.getBounds(); - - // append clusterUuid if the parameter is given - let url = '../api/monitoring/v1/clusters'; - if (clusterUuid) { - url += `/${clusterUuid}`; - } - - const $http = $injector.get('$http'); - - async function getClusters() { - try { - const response = await $http.post( - url, - { - ccs, - timeRange: { - min: min.toISOString(), - max: max.toISOString(), - }, - codePaths, - }, - { headers: { 'kbn-system-request': 'true' } } - ); - return formatClusters(response.data); - } catch (err) { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - } - } - - return await getClusters(); - }; -} diff --git a/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js b/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js deleted file mode 100644 index 438c5ab83f5e3..0000000000000 --- a/x-pack/plugins/monitoring/public/services/enable_alerts_modal.js +++ /dev/null @@ -1,51 +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 { ajaxErrorHandlersProvider } from '../lib/ajax_error_handler'; -import { showAlertsToast } from '../alerts/lib/alerts_toast'; - -export function enableAlertsModalProvider($http, $window, $injector) { - function shouldShowAlertsModal(alerts) { - const modalHasBeenShown = $window.sessionStorage.getItem('ALERTS_MODAL_HAS_BEEN_SHOWN'); - const decisionMade = $window.localStorage.getItem('ALERTS_MODAL_DECISION_MADE'); - - if (Object.keys(alerts).length > 0) { - $window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', true); - return false; - } else if (!modalHasBeenShown && !decisionMade) { - return true; - } - - return false; - } - - async function enableAlerts() { - try { - const { data } = await $http.post('../api/monitoring/v1/alerts/enable', {}); - $window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', true); - showAlertsToast(data); - } catch (err) { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - } - } - - function notAskAgain() { - $window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', true); - } - - function hideModalForSession() { - $window.sessionStorage.setItem('ALERTS_MODAL_HAS_BEEN_SHOWN', true); - } - - return { - shouldShowAlertsModal, - enableAlerts, - notAskAgain, - hideModalForSession, - }; -} diff --git a/x-pack/plugins/monitoring/public/services/executor.js b/x-pack/plugins/monitoring/public/services/executor.js deleted file mode 100644 index 60b2c171eac32..0000000000000 --- a/x-pack/plugins/monitoring/public/services/executor.js +++ /dev/null @@ -1,130 +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 { Legacy } from '../legacy_shims'; -import { subscribeWithScope } from '../angular/helpers/utils'; -import { Subscription } from 'rxjs'; - -export function executorProvider($timeout, $q) { - const queue = []; - const subscriptions = new Subscription(); - let executionTimer; - let ignorePaused = false; - - /** - * Resets the timer to start again - * @returns {void} - */ - function reset() { - cancel(); - start(); - } - - function killTimer() { - if (executionTimer) { - $timeout.cancel(executionTimer); - } - } - - /** - * Cancels the execution timer - * @returns {void} - */ - function cancel() { - killTimer(); - } - - /** - * Registers a service with the executor - * @param {object} service The service to register - * @returns {void} - */ - function register(service) { - queue.push(service); - } - - /** - * Stops the executor and empties the service queue - * @returns {void} - */ - function destroy() { - subscriptions.unsubscribe(); - cancel(); - ignorePaused = false; - queue.splice(0, queue.length); - } - - /** - * Runs the queue (all at once) - * @returns {Promise} a promise of all the services - */ - function run() { - const noop = () => $q.resolve(); - return $q - .all( - queue.map((service) => { - return service - .execute() - .then(service.handleResponse || noop) - .catch(service.handleError || noop); - }) - ) - .finally(reset); - } - - function reFetch() { - cancel(); - run(); - } - - function killIfPaused() { - if (Legacy.shims.timefilter.getRefreshInterval().pause) { - killTimer(); - } - } - - /** - * Starts the executor service if the timefilter is not paused - * @returns {void} - */ - function start() { - const timefilter = Legacy.shims.timefilter; - if ( - (ignorePaused || timefilter.getRefreshInterval().pause === false) && - timefilter.getRefreshInterval().value > 0 - ) { - executionTimer = $timeout(run, timefilter.getRefreshInterval().value); - } - } - - /** - * Expose the methods - */ - return { - register, - start($scope) { - $scope.$applyAsync(() => { - const timefilter = Legacy.shims.timefilter; - subscriptions.add( - subscribeWithScope($scope, timefilter.getFetch$(), { - next: reFetch, - }) - ); - subscriptions.add( - subscribeWithScope($scope, timefilter.getRefreshIntervalUpdate$(), { - next: killIfPaused, - }) - ); - start(); - }); - }, - run, - destroy, - reset, - cancel, - }; -} diff --git a/x-pack/plugins/monitoring/public/services/features.js b/x-pack/plugins/monitoring/public/services/features.js deleted file mode 100644 index 34564f79c9247..0000000000000 --- a/x-pack/plugins/monitoring/public/services/features.js +++ /dev/null @@ -1,47 +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 { has, isUndefined } from 'lodash'; - -export function featuresProvider($window) { - function getData() { - let returnData = {}; - const monitoringData = $window.localStorage.getItem('xpack.monitoring.data'); - - try { - returnData = (monitoringData && JSON.parse(monitoringData)) || {}; - } catch (e) { - console.error('Monitoring UI: error parsing locally stored monitoring data', e); - } - - return returnData; - } - - function update(featureName, value) { - const monitoringDataObj = getData(); - monitoringDataObj[featureName] = value; - $window.localStorage.setItem('xpack.monitoring.data', JSON.stringify(monitoringDataObj)); - } - - function isEnabled(featureName, defaultSetting) { - const monitoringDataObj = getData(); - if (has(monitoringDataObj, featureName)) { - return monitoringDataObj[featureName]; - } - - if (isUndefined(defaultSetting)) { - return false; - } - - return defaultSetting; - } - - return { - isEnabled, - update, - }; -} diff --git a/x-pack/plugins/monitoring/public/services/license.js b/x-pack/plugins/monitoring/public/services/license.js deleted file mode 100644 index cab5ad01cf58a..0000000000000 --- a/x-pack/plugins/monitoring/public/services/license.js +++ /dev/null @@ -1,52 +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 { includes } from 'lodash'; -import { ML_SUPPORTED_LICENSES } from '../../common/constants'; - -export function licenseProvider() { - return new (class LicenseService { - constructor() { - // do not initialize with usable state - this.license = { - type: null, - expiry_date_in_millis: -Infinity, - }; - } - - // we're required to call this initially - setLicense(license) { - this.license = license; - } - - isBasic() { - return this.license.type === 'basic'; - } - - mlIsSupported() { - return includes(ML_SUPPORTED_LICENSES, this.license.type); - } - - doesExpire() { - const { expiry_date_in_millis: expiryDateInMillis } = this.license; - return expiryDateInMillis !== undefined; - } - - isActive() { - const { expiry_date_in_millis: expiryDateInMillis } = this.license; - return new Date().getTime() < expiryDateInMillis; - } - - isExpired() { - if (this.doesExpire()) { - const { expiry_date_in_millis: expiryDateInMillis } = this.license; - return new Date().getTime() >= expiryDateInMillis; - } - return false; - } - })(); -} diff --git a/x-pack/plugins/monitoring/public/services/title.js b/x-pack/plugins/monitoring/public/services/title.js deleted file mode 100644 index e12d4936584fa..0000000000000 --- a/x-pack/plugins/monitoring/public/services/title.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { Legacy } from '../legacy_shims'; - -export function titleProvider($rootScope) { - return function changeTitle(cluster, suffix) { - let clusterName = get(cluster, 'cluster_name'); - clusterName = clusterName ? `- ${clusterName}` : ''; - suffix = suffix ? `- ${suffix}` : ''; - $rootScope.$applyAsync(() => { - Legacy.shims.docTitle.change( - i18n.translate('xpack.monitoring.stackMonitoringDocTitle', { - defaultMessage: 'Stack Monitoring {clusterName} {suffix}', - values: { clusterName, suffix }, - }) - ); - }); - }; -} diff --git a/x-pack/plugins/monitoring/public/views/access_denied/index.html b/x-pack/plugins/monitoring/public/views/access_denied/index.html deleted file mode 100644 index 24863559212f7..0000000000000 --- a/x-pack/plugins/monitoring/public/views/access_denied/index.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
-
- - -
- -
-
- -
- -
-
- - - -
-
-
-
-
diff --git a/x-pack/plugins/monitoring/public/views/access_denied/index.js b/x-pack/plugins/monitoring/public/views/access_denied/index.js deleted file mode 100644 index e52df61dd8966..0000000000000 --- a/x-pack/plugins/monitoring/public/views/access_denied/index.js +++ /dev/null @@ -1,44 +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 { uiRoutes } from '../../angular/helpers/routes'; -import template from './index.html'; - -const tryPrivilege = ($http) => { - return $http - .get('../api/monitoring/v1/check_access') - .then(() => window.history.replaceState(null, null, '#/home')) - .catch(() => true); -}; - -uiRoutes.when('/access-denied', { - template, - resolve: { - /* - * The user may have been granted privileges in between leaving Monitoring - * and before coming back to Monitoring. That means, they just be on this - * page because Kibana remembers the "last app URL". We check for the - * privilege one time up front (doing it in the resolve makes it happen - * before the template renders), and then keep retrying every 5 seconds. - */ - initialCheck($http) { - return tryPrivilege($http); - }, - }, - controllerAs: 'accessDenied', - controller: function ($scope, $injector) { - const $http = $injector.get('$http'); - const $interval = $injector.get('$interval'); - - // The template's "Back to Kibana" button click handler - this.goToKibanaURL = '/app/home'; - - // keep trying to load data in the background - const accessPoller = $interval(() => tryPrivilege($http), 5 * 1000); // every 5 seconds - $scope.$on('$destroy', () => $interval.cancel(accessPoller)); - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/all.js b/x-pack/plugins/monitoring/public/views/all.js deleted file mode 100644 index 3af0c85d95687..0000000000000 --- a/x-pack/plugins/monitoring/public/views/all.js +++ /dev/null @@ -1,39 +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 './no_data'; -import './access_denied'; -import './license'; -import './cluster/listing'; -import './cluster/overview'; -import './elasticsearch/overview'; -import './elasticsearch/indices'; -import './elasticsearch/index'; -import './elasticsearch/index/advanced'; -import './elasticsearch/nodes'; -import './elasticsearch/node'; -import './elasticsearch/node/advanced'; -import './elasticsearch/ccr'; -import './elasticsearch/ccr/shard'; -import './elasticsearch/ml_jobs'; -import './kibana/overview'; -import './kibana/instances'; -import './kibana/instance'; -import './logstash/overview'; -import './logstash/nodes'; -import './logstash/node'; -import './logstash/node/advanced'; -import './logstash/node/pipelines'; -import './logstash/pipelines'; -import './logstash/pipeline'; -import './beats/overview'; -import './beats/listing'; -import './beats/beat'; -import './apm/overview'; -import './apm/instances'; -import './apm/instance'; -import './loading'; diff --git a/x-pack/plugins/monitoring/public/views/apm/instance/index.html b/x-pack/plugins/monitoring/public/views/apm/instance/index.html deleted file mode 100644 index 79579990eb649..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/instance/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/apm/instance/index.js b/x-pack/plugins/monitoring/public/views/apm/instance/index.js deleted file mode 100644 index 0d733036bb266..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/instance/index.js +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find, get } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { ApmServerInstance } from '../../../components/apm/instance'; -import { CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm/instances/:uuid', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const title = $injector.get('title'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - super({ - title: i18n.translate('xpack.monitoring.apm.instance.routeTitle', { - defaultMessage: '{apm} - Instance', - values: { - apm: 'APM server', - }, - }), - telemetryPageViewTitle: 'apm_server_instance', - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm/${$route.current.params.uuid}`, - defaultData: {}, - reactNodeId: 'apmInstanceReact', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - (data) => { - this.setPageTitle( - i18n.translate('xpack.monitoring.apm.instance.pageTitle', { - defaultMessage: 'APM server instance: {instanceName}', - values: { - instanceName: get(data, 'apmSummary.name'), - }, - }) - ); - title($scope.cluster, `APM server - ${get(data, 'apmSummary.name')}`); - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/apm/instances/index.html b/x-pack/plugins/monitoring/public/views/apm/instances/index.html deleted file mode 100644 index fd8029e277d78..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/instances/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/apm/instances/index.js b/x-pack/plugins/monitoring/public/views/apm/instances/index.js deleted file mode 100644 index f9747ec176e86..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/instances/index.js +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { ApmServerInstances } from '../../../components/apm/instances'; -import { MonitoringViewBaseEuiTableController } from '../..'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { APM_SYSTEM_ID, CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm/instances', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - controller: class extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.apm.instances.routeTitle', { - defaultMessage: '{apm} - Instances', - values: { - apm: 'APM server', - }, - }), - pageTitle: i18n.translate('xpack.monitoring.apm.instances.pageTitle', { - defaultMessage: 'APM server instances', - }), - storageKey: 'apm.instances', - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm/instances`, - defaultData: {}, - reactNodeId: 'apmInstancesReact', - $scope, - $injector, - }); - - this.scope = $scope; - this.injector = $injector; - this.onTableChangeRender = this.renderComponent; - - $scope.$watch( - () => this.data, - () => this.renderComponent() - ); - } - - renderComponent() { - const { pagination, sorting, onTableChange } = this; - - const component = ( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - this.renderReact(component); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/apm/overview/index.html b/x-pack/plugins/monitoring/public/views/apm/overview/index.html deleted file mode 100644 index 0cf804e377476..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/overview/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/apm/overview/index.js b/x-pack/plugins/monitoring/public/views/apm/overview/index.js deleted file mode 100644 index bef17bf4a2fad..0000000000000 --- a/x-pack/plugins/monitoring/public/views/apm/overview/index.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { ApmOverview } from '../../../components/apm/overview'; -import { CODE_PATH_APM } from '../../../../common/constants'; - -uiRoutes.when('/apm', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_APM] }); - }, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.apm.overview.routeTitle', { - defaultMessage: 'APM server', - }), - pageTitle: i18n.translate('xpack.monitoring.apm.overview.pageTitle', { - defaultMessage: 'APM server overview', - }), - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/apm`, - defaultData: {}, - reactNodeId: 'apmOverviewReact', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/base_controller.js b/x-pack/plugins/monitoring/public/views/base_controller.js deleted file mode 100644 index dd9898a6e195c..0000000000000 --- a/x-pack/plugins/monitoring/public/views/base_controller.js +++ /dev/null @@ -1,271 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import moment from 'moment'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { getPageData } from '../lib/get_page_data'; -import { PageLoading } from '../components'; -import { Legacy } from '../legacy_shims'; -import { PromiseWithCancel } from '../../common/cancel_promise'; -import { SetupModeFeature } from '../../common/enums'; -import { updateSetupModeData, isSetupModeFeatureEnabled } from '../lib/setup_mode'; -import { AlertsContext } from '../alerts/context'; -import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public'; -import { AlertsDropdown } from '../alerts/alerts_dropdown'; -import { HeaderMenuPortal } from '../../../observability/public'; - -/** - * Given a timezone, this function will calculate the offset in milliseconds - * from UTC time. - * - * @param {string} timezone - */ -const getOffsetInMS = (timezone) => { - if (timezone === 'Browser') { - return 0; - } - const offsetInMinutes = moment.tz(timezone).utcOffset(); - const offsetInMS = offsetInMinutes * 1 * 60 * 1000; - return offsetInMS; -}; - -/** - * Class to manage common instantiation behaviors in a view controller - * - * This is expected to be extended, and behavior enabled using super(); - * - * Example: - * uiRoutes.when('/myRoute', { - * template: importedTemplate, - * controllerAs: 'myView', - * controller: class MyView extends MonitoringViewBaseController { - * constructor($injector, $scope) { - * super({ - * title: 'Hello World', - * api: '../api/v1/monitoring/foo/bar', - * defaultData, - * reactNodeId, - * $scope, - * $injector, - * options: { - * enableTimeFilter: false // this will have just the page auto-refresh control show - * } - * }); - * } - * } - * }); - */ -export class MonitoringViewBaseController { - /** - * Create a view controller - * @param {String} title - Title of the page - * @param {String} api - Back-end API endpoint to poll for getting the page - * data using POST and time range data in the body. Whenever possible, use - * this method for data polling rather than supply the getPageData param. - * @param {Function} apiUrlFn - Function that returns a string for the back-end - * API endpoint, in case the string has dynamic query parameters (e.g. - * show_system_indices) rather than supply the getPageData param. - * @param {Function} getPageData - (Optional) Function to fetch page data, if - * simply passing the API string isn't workable. - * @param {Object} defaultData - Initial model data to populate - * @param {String} reactNodeId - DOM element ID of the element for mounting - * the view's main React component - * @param {Service} $injector - Angular dependency injection service - * @param {Service} $scope - Angular view data binding service - * @param {Boolean} options.enableTimeFilter - Whether to show the time filter - * @param {Boolean} options.enableAutoRefresh - Whether to show the auto - * refresh control - */ - constructor({ - title = '', - pageTitle = '', - api = '', - apiUrlFn, - getPageData: _getPageData = getPageData, - defaultData, - reactNodeId = null, // WIP: https://github.com/elastic/x-pack-kibana/issues/5198 - $scope, - $injector, - options = {}, - alerts = { shouldFetch: false, options: {} }, - fetchDataImmediately = true, - telemetryPageViewTitle = '', - }) { - const titleService = $injector.get('title'); - const $executor = $injector.get('$executor'); - const $window = $injector.get('$window'); - const config = $injector.get('config'); - - titleService($scope.cluster, title); - - $scope.pageTitle = pageTitle; - this.setPageTitle = (title) => ($scope.pageTitle = title); - $scope.pageData = this.data = { ...defaultData }; - this._isDataInitialized = false; - this.reactNodeId = reactNodeId; - this.telemetryPageViewTitle = telemetryPageViewTitle || title; - - let deferTimer; - let zoomInLevel = 0; - - const popstateHandler = () => zoomInLevel > 0 && --zoomInLevel; - const removePopstateHandler = () => $window.removeEventListener('popstate', popstateHandler); - const addPopstateHandler = () => $window.addEventListener('popstate', popstateHandler); - - this.zoomInfo = { - zoomOutHandler: () => $window.history.back(), - showZoomOutBtn: () => zoomInLevel > 0, - }; - - const { enableTimeFilter = true, enableAutoRefresh = true } = options; - - async function fetchAlerts() { - const globalState = $injector.get('globalState'); - const bounds = Legacy.shims.timefilter.getBounds(); - const min = bounds.min?.valueOf(); - const max = bounds.max?.valueOf(); - const options = alerts.options || {}; - try { - return await Legacy.shims.http.post( - `/api/monitoring/v1/alert/${globalState.cluster_uuid}/status`, - { - body: JSON.stringify({ - alertTypeIds: options.alertTypeIds, - filters: options.filters, - timeRange: { - min, - max, - }, - }), - } - ); - } catch (err) { - Legacy.shims.toastNotifications.addDanger({ - title: 'Error fetching alert status', - text: err.message, - }); - } - } - - this.updateData = () => { - if (this.updateDataPromise) { - // Do not sent another request if one is inflight - // See https://github.com/elastic/kibana/issues/24082 - this.updateDataPromise.cancel(); - this.updateDataPromise = null; - } - const _api = apiUrlFn ? apiUrlFn() : api; - const promises = [_getPageData($injector, _api, this.getPaginationRouteOptions())]; - if (alerts.shouldFetch) { - promises.push(fetchAlerts()); - } - if (isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { - promises.push(updateSetupModeData()); - } - this.updateDataPromise = new PromiseWithCancel(Promise.allSettled(promises)); - return this.updateDataPromise.promise().then(([pageData, alerts]) => { - $scope.$apply(() => { - this._isDataInitialized = true; // render will replace loading screen with the react component - $scope.pageData = this.data = pageData.value; // update the view's data with the fetch result - $scope.alerts = this.alerts = alerts && alerts.value ? alerts.value : {}; - }); - }); - }; - - $scope.$applyAsync(() => { - const timefilter = Legacy.shims.timefilter; - - if (enableTimeFilter === false) { - timefilter.disableTimeRangeSelector(); - } else { - timefilter.enableTimeRangeSelector(); - } - - if (enableAutoRefresh === false) { - timefilter.disableAutoRefreshSelector(); - } else { - timefilter.enableAutoRefreshSelector(); - } - - // needed for chart pages - this.onBrush = ({ xaxis }) => { - removePopstateHandler(); - const { to, from } = xaxis; - const timezone = config.get('dateFormat:tz'); - const offset = getOffsetInMS(timezone); - timefilter.setTime({ - from: moment(from - offset), - to: moment(to - offset), - mode: 'absolute', - }); - $executor.cancel(); - $executor.run(); - ++zoomInLevel; - clearTimeout(deferTimer); - /* - Needed to defer 'popstate' event, so it does not fire immediately after it's added. - 10ms is to make sure the event is not added with the same code digest - */ - deferTimer = setTimeout(() => addPopstateHandler(), 10); - }; - - // Render loading state - this.renderReact(null, true); - fetchDataImmediately && this.updateData(); - }); - - $executor.register({ - execute: () => this.updateData(), - }); - $executor.start($scope); - $scope.$on('$destroy', () => { - clearTimeout(deferTimer); - removePopstateHandler(); - const targetElement = document.getElementById(this.reactNodeId); - if (targetElement) { - // WIP https://github.com/elastic/x-pack-kibana/issues/5198 - unmountComponentAtNode(targetElement); - } - $executor.destroy(); - }); - - this.setTitle = (title) => titleService($scope.cluster, title); - } - - renderReact(component, trackPageView = false) { - const renderElement = document.getElementById(this.reactNodeId); - if (!renderElement) { - console.warn(`"#${this.reactNodeId}" element has not been added to the DOM yet`); - return; - } - const I18nContext = Legacy.shims.I18nContext; - const wrappedComponent = ( - - - - - - - {!this._isDataInitialized ? ( - - ) : ( - component - )} - - - - ); - render(wrappedComponent, renderElement); - } - - getPaginationRouteOptions() { - return {}; - } -} diff --git a/x-pack/plugins/monitoring/public/views/base_eui_table_controller.js b/x-pack/plugins/monitoring/public/views/base_eui_table_controller.js deleted file mode 100644 index 0520ce3f10de5..0000000000000 --- a/x-pack/plugins/monitoring/public/views/base_eui_table_controller.js +++ /dev/null @@ -1,135 +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 { MonitoringViewBaseController } from './'; -import { euiTableStorageGetter, euiTableStorageSetter } from '../components/table'; -import { EUI_SORT_ASCENDING } from '../../common/constants'; - -const PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; - -/** - * Class to manage common instantiation behaviors in a view controller - * And add persistent state to a table: - * - page index: in table pagination, which page are we looking at - * - filter text: what filter was entered in the table's filter bar - * - sortKey: which column field of table data is used for sorting - * - sortOrder: is sorting ordered ascending or descending - * - * This is expected to be extended, and behavior enabled using super(); - */ -export class MonitoringViewBaseEuiTableController extends MonitoringViewBaseController { - /** - * Create a table view controller - * - used by parent class: - * @param {String} title - Title of the page - * @param {Function} getPageData - Function to fetch page data - * @param {Service} $injector - Angular dependency injection service - * @param {Service} $scope - Angular view data binding service - * @param {Boolean} options.enableTimeFilter - Whether to show the time filter - * @param {Boolean} options.enableAutoRefresh - Whether to show the auto refresh control - * - specific to this class: - * @param {String} storageKey - the namespace that will be used to keep the state data in the Monitoring localStorage object - * - */ - constructor(args) { - super(args); - const { storageKey, $injector } = args; - const storage = $injector.get('localStorage'); - - const getLocalStorageData = euiTableStorageGetter(storageKey); - const setLocalStorageData = euiTableStorageSetter(storageKey); - const { page, sort } = getLocalStorageData(storage); - - this.pagination = { - pageSize: 20, - initialPageSize: 20, - pageIndex: 0, - initialPageIndex: 0, - pageSizeOptions: PAGE_SIZE_OPTIONS, - }; - - if (page) { - if (!PAGE_SIZE_OPTIONS.includes(page.size)) { - page.size = 20; - } - this.setPagination(page); - } - - this.setSorting(sort); - - this.onTableChange = ({ page, sort }) => { - this.setPagination(page); - this.setSorting({ sort }); - setLocalStorageData(storage, { - page, - sort: { - sort, - }, - }); - if (this.onTableChangeRender) { - this.onTableChangeRender(); - } - }; - - // For pages where we do not fetch immediately, we want to fetch after pagination is applied - args.fetchDataImmediately === false && this.updateData(); - } - - setPagination(page) { - this.pagination = { - initialPageSize: page.size, - pageSize: page.size, - initialPageIndex: page.index, - pageIndex: page.index, - pageSizeOptions: PAGE_SIZE_OPTIONS, - }; - } - - setSorting(sort) { - this.sorting = sort || { sort: {} }; - - if (!this.sorting.sort.field) { - this.sorting.sort.field = 'name'; - } - if (!this.sorting.sort.direction) { - this.sorting.sort.direction = EUI_SORT_ASCENDING; - } - } - - setQueryText(queryText) { - this.queryText = queryText; - } - - getPaginationRouteOptions() { - if (!this.pagination || !this.sorting) { - return {}; - } - - return { - pagination: { - size: this.pagination.pageSize, - index: this.pagination.pageIndex, - }, - ...this.sorting, - queryText: this.queryText, - }; - } - - getPaginationTableProps(pagination) { - return { - sorting: this.sorting, - pagination: pagination, - onTableChange: this.onTableChange, - fetchMoreData: async ({ page, sort, queryText }) => { - this.setPagination(page); - this.setSorting(sort); - this.setQueryText(queryText); - await this.updateData(); - }, - }; - } -} diff --git a/x-pack/plugins/monitoring/public/views/base_table_controller.js b/x-pack/plugins/monitoring/public/views/base_table_controller.js deleted file mode 100644 index a066a91e48c8b..0000000000000 --- a/x-pack/plugins/monitoring/public/views/base_table_controller.js +++ /dev/null @@ -1,53 +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 { MonitoringViewBaseController } from './'; -import { tableStorageGetter, tableStorageSetter } from '../components/table'; - -/** - * Class to manage common instantiation behaviors in a view controller - * And add persistent state to a table: - * - page index: in table pagination, which page are we looking at - * - filter text: what filter was entered in the table's filter bar - * - sortKey: which column field of table data is used for sorting - * - sortOrder: is sorting ordered ascending or descending - * - * This is expected to be extended, and behavior enabled using super(); - */ -export class MonitoringViewBaseTableController extends MonitoringViewBaseController { - /** - * Create a table view controller - * - used by parent class: - * @param {String} title - Title of the page - * @param {Function} getPageData - Function to fetch page data - * @param {Service} $injector - Angular dependency injection service - * @param {Service} $scope - Angular view data binding service - * @param {Boolean} options.enableTimeFilter - Whether to show the time filter - * @param {Boolean} options.enableAutoRefresh - Whether to show the auto refresh control - * - specific to this class: - * @param {String} storageKey - the namespace that will be used to keep the state data in the Monitoring localStorage object - * - */ - constructor(args) { - super(args); - const { storageKey, $injector } = args; - const storage = $injector.get('localStorage'); - - const getLocalStorageData = tableStorageGetter(storageKey); - const setLocalStorageData = tableStorageSetter(storageKey); - const { pageIndex, filterText, sortKey, sortOrder } = getLocalStorageData(storage); - - this.pageIndex = pageIndex; - this.filterText = filterText; - this.sortKey = sortKey; - this.sortOrder = sortOrder; - - this.onNewState = (newState) => { - setLocalStorageData(storage, newState); - }; - } -} diff --git a/x-pack/plugins/monitoring/public/views/beats/beat/get_page_data.js b/x-pack/plugins/monitoring/public/views/beats/beat/get_page_data.js deleted file mode 100644 index 7f87fa413d8ca..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/beat/get_page_data.js +++ /dev/null @@ -1,32 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats/beat/${$route.current.params.beatUuid}`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/beats/beat/index.html b/x-pack/plugins/monitoring/public/views/beats/beat/index.html deleted file mode 100644 index 6ae727e31cbeb..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/beat/index.html +++ /dev/null @@ -1,11 +0,0 @@ - -
- -
diff --git a/x-pack/plugins/monitoring/public/views/beats/beat/index.js b/x-pack/plugins/monitoring/public/views/beats/beat/index.js deleted file mode 100644 index f1a171a19cd89..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/beat/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_BEATS } from '../../../../common/constants'; -import { Beat } from '../../../components/beats/beat'; - -uiRoutes.when('/beats/beat/:beatUuid', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beat', - controller: class BeatDetail extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - const pageData = $route.current.locals.pageData; - super({ - title: i18n.translate('xpack.monitoring.beats.instance.routeTitle', { - defaultMessage: 'Beats - {instanceName} - Overview', - values: { - instanceName: pageData.summary.name, - }, - }), - pageTitle: i18n.translate('xpack.monitoring.beats.instance.pageTitle', { - defaultMessage: 'Beat instance: {beatName}', - values: { - beatName: pageData.summary.name, - }, - }), - telemetryPageViewTitle: 'beats_instance', - getPageData, - $scope, - $injector, - reactNodeId: 'monitoringBeatsInstanceApp', - }); - - this.data = pageData; - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/beats/listing/get_page_data.js b/x-pack/plugins/monitoring/public/views/beats/listing/get_page_data.js deleted file mode 100644 index 99366f05f3ad4..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/listing/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats/beats`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/beats/listing/index.html b/x-pack/plugins/monitoring/public/views/beats/listing/index.html deleted file mode 100644 index 0ce66a6848dfd..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/listing/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
\ No newline at end of file diff --git a/x-pack/plugins/monitoring/public/views/beats/listing/index.js b/x-pack/plugins/monitoring/public/views/beats/listing/index.js deleted file mode 100644 index eae74d8a08b9e..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/listing/index.js +++ /dev/null @@ -1,89 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import React from 'react'; -import { Listing } from '../../../components/beats/listing/listing'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { CODE_PATH_BEATS, BEATS_SYSTEM_ID } from '../../../../common/constants'; - -uiRoutes.when('/beats/beats', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beats', - controller: class BeatsListing extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.beats.routeTitle', { defaultMessage: 'Beats' }), - pageTitle: i18n.translate('xpack.monitoring.beats.listing.pageTitle', { - defaultMessage: 'Beats listing', - }), - telemetryPageViewTitle: 'beats_listing', - storageKey: 'beats.beats', - getPageData, - reactNodeId: 'monitoringBeatsInstancesApp', - $scope, - $injector, - }); - - this.data = $route.current.locals.pageData; - this.scope = $scope; - this.injector = $injector; - this.onTableChangeRender = this.renderComponent; - - $scope.$watch( - () => this.data, - () => this.renderComponent() - ); - } - - renderComponent() { - const { sorting, pagination, onTableChange } = this.scope.beats; - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/beats/overview/get_page_data.js b/x-pack/plugins/monitoring/public/views/beats/overview/get_page_data.js deleted file mode 100644 index 497ed8cdb0e74..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/overview/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/beats`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/beats/overview/index.html b/x-pack/plugins/monitoring/public/views/beats/overview/index.html deleted file mode 100644 index 0b827c96f68fd..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/overview/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/beats/overview/index.js b/x-pack/plugins/monitoring/public/views/beats/overview/index.js deleted file mode 100644 index 475a63d440c76..0000000000000 --- a/x-pack/plugins/monitoring/public/views/beats/overview/index.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_BEATS } from '../../../../common/constants'; -import { BeatsOverview } from '../../../components/beats/overview'; - -uiRoutes.when('/beats', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_BEATS] }); - }, - pageData: getPageData, - }, - controllerAs: 'beats', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: i18n.translate('xpack.monitoring.beats.overview.routeTitle', { - defaultMessage: 'Beats - Overview', - }), - pageTitle: i18n.translate('xpack.monitoring.beats.overview.pageTitle', { - defaultMessage: 'Beats overview', - }), - getPageData, - $scope, - $injector, - reactNodeId: 'monitoringBeatsOverviewApp', - }); - - this.data = $route.current.locals.pageData; - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/cluster/listing/index.html b/x-pack/plugins/monitoring/public/views/cluster/listing/index.html deleted file mode 100644 index 713ca8fb1ffc9..0000000000000 --- a/x-pack/plugins/monitoring/public/views/cluster/listing/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/cluster/listing/index.js b/x-pack/plugins/monitoring/public/views/cluster/listing/index.js deleted file mode 100644 index 8b365292aeb13..0000000000000 --- a/x-pack/plugins/monitoring/public/views/cluster/listing/index.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import template from './index.html'; -import { Listing } from '../../../components/cluster/listing'; -import { CODE_PATH_ALL } from '../../../../common/constants'; -import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal.tsx'; - -const CODE_PATHS = [CODE_PATH_ALL]; - -const getPageData = ($injector) => { - const monitoringClusters = $injector.get('monitoringClusters'); - return monitoringClusters(undefined, undefined, CODE_PATHS); -}; - -const getAlerts = (clusters) => { - return clusters.reduce((alerts, cluster) => ({ ...alerts, ...cluster.alerts.list }), {}); -}; - -uiRoutes - .when('/home', { - template, - resolve: { - clusters: (Private) => { - const routeInit = Private(routeInitProvider); - return routeInit({ - codePaths: CODE_PATHS, - fetchAllClusters: true, - unsetGlobalState: true, - }).then((clusters) => { - if (!clusters || !clusters.length) { - window.location.hash = '#/no-data'; - return Promise.reject(); - } - if (clusters.length === 1) { - // Bypass the cluster listing if there is just 1 cluster - window.history.replaceState(null, null, '#/overview'); - return Promise.reject(); - } - return clusters; - }); - }, - }, - controllerAs: 'clusters', - controller: class ClustersList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - storageKey: 'clusters', - pageTitle: i18n.translate('xpack.monitoring.cluster.listing.pageTitle', { - defaultMessage: 'Cluster listing', - }), - getPageData, - $scope, - $injector, - reactNodeId: 'monitoringClusterListingApp', - telemetryPageViewTitle: 'cluster_listing', - }); - - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const storage = $injector.get('localStorage'); - const showLicenseExpiration = $injector.get('showLicenseExpiration'); - - this.data = $route.current.locals.clusters; - - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - <> - - - - ); - } - ); - } - }, - }) - .otherwise({ redirectTo: '/loading' }); diff --git a/x-pack/plugins/monitoring/public/views/cluster/overview/index.html b/x-pack/plugins/monitoring/public/views/cluster/overview/index.html deleted file mode 100644 index 1762ee1c2a282..0000000000000 --- a/x-pack/plugins/monitoring/public/views/cluster/overview/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/cluster/overview/index.js b/x-pack/plugins/monitoring/public/views/cluster/overview/index.js deleted file mode 100644 index 20e694ad8548f..0000000000000 --- a/x-pack/plugins/monitoring/public/views/cluster/overview/index.js +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { isEmpty } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../'; -import { Overview } from '../../../components/cluster/overview'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { CODE_PATH_ALL } from '../../../../common/constants'; -import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal.tsx'; - -const CODE_PATHS = [CODE_PATH_ALL]; - -uiRoutes.when('/overview', { - template, - resolve: { - clusters(Private) { - // checks license info of all monitored clusters for multi-cluster monitoring usage and capability - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: CODE_PATHS }); - }, - }, - controllerAs: 'monitoringClusterOverview', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const monitoringClusters = $injector.get('monitoringClusters'); - const globalState = $injector.get('globalState'); - const showLicenseExpiration = $injector.get('showLicenseExpiration'); - - super({ - title: i18n.translate('xpack.monitoring.cluster.overviewTitle', { - defaultMessage: 'Overview', - }), - pageTitle: i18n.translate('xpack.monitoring.cluster.overview.pageTitle', { - defaultMessage: 'Cluster overview', - }), - defaultData: {}, - getPageData: async () => { - const clusters = await monitoringClusters( - globalState.cluster_uuid, - globalState.ccs, - CODE_PATHS - ); - return clusters[0]; - }, - reactNodeId: 'monitoringClusterOverviewApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - }, - telemetryPageViewTitle: 'cluster_overview', - }); - - this.init = () => this.renderReact(null); - - $scope.$watch( - () => this.data, - async (data) => { - if (isEmpty(data)) { - return; - } - - this.renderReact( - ( - - {flyoutComponent} - - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js deleted file mode 100644 index 4f45038986332..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ccr`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.html deleted file mode 100644 index ca0b036ae39e1..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.js deleted file mode 100644 index 91cc9c8782b22..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/index.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { getPageData } from './get_page_data'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { Ccr } from '../../../components/elasticsearch/ccr'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_CCR_READ_EXCEPTIONS, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../../common/constants'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; - -uiRoutes.when('/elasticsearch/ccr', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'elasticsearchCcr', - controller: class ElasticsearchCcrController extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.ccr.routeTitle', { - defaultMessage: 'Elasticsearch - Ccr', - }), - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.ccr.pageTitle', { - defaultMessage: 'Elasticsearch Ccr', - }), - reactNodeId: 'elasticsearchCcrReact', - getPageData, - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], - }, - }, - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data) { - return; - } - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js deleted file mode 100644 index ca1aad39e3610..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/get_page_data.js +++ /dev/null @@ -1,32 +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 { ajaxErrorHandlersProvider } from '../../../../lib/ajax_error_handler'; -import { Legacy } from '../../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ccr/${$route.current.params.index}/shard/${$route.current.params.shardId}`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.html deleted file mode 100644 index 76469e5d9add5..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js deleted file mode 100644 index 767fb18685633..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ccr/shard/index.js +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { uiRoutes } from '../../../../angular/helpers/routes'; -import { getPageData } from './get_page_data'; -import { routeInitProvider } from '../../../../lib/route_init'; -import template from './index.html'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { CcrShard } from '../../../../components/elasticsearch/ccr_shard'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_CCR_READ_EXCEPTIONS, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../../../common/constants'; -import { SetupModeRenderer } from '../../../../components/renderers'; -import { SetupModeContext } from '../../../../components/setup_mode/setup_mode_context'; - -uiRoutes.when('/elasticsearch/ccr/:index/shard/:shardId', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'elasticsearchCcr', - controller: class ElasticsearchCcrController extends MonitoringViewBaseController { - constructor($injector, $scope, pageData) { - const $route = $injector.get('$route'); - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.routeTitle', { - defaultMessage: 'Elasticsearch - Ccr - Shard', - }), - reactNodeId: 'elasticsearchCcrShardReact', - getPageData, - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], - filters: [ - { - shardId: $route.current.pathParams.shardId, - }, - ], - }, - }, - }); - - $scope.instance = i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.instanceTitle', { - defaultMessage: 'Index: {followerIndex} Shard: {shardId}', - values: { - followerIndex: get(pageData, 'stat.follower_index'), - shardId: get(pageData, 'stat.shard_id'), - }, - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data) { - return; - } - - this.setPageTitle( - i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.pageTitle', { - defaultMessage: 'Elasticsearch Ccr Shard - Index: {followerIndex} Shard: {shardId}', - values: { - followerIndex: get( - pageData, - 'stat.follower.index', - get(pageData, 'stat.follower_index') - ), - shardId: get( - pageData, - 'stat.follower.shard.number', - get(pageData, 'stat.shard_id') - ), - }, - }) - ); - - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.html deleted file mode 100644 index 159376148d173..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js deleted file mode 100644 index 9276527951612..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/index/advanced/index.js +++ /dev/null @@ -1,124 +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. - */ - -/** - * Controller for Advanced Index Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../../legacy_shims'; -import { AdvancedIndex } from '../../../../components/elasticsearch/index/advanced'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_LARGE_SHARD_SIZE, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../../../common/constants'; -import { SetupModeContext } from '../../../../components/setup_mode/setup_mode_context'; -import { SetupModeRenderer } from '../../../../components/renderers'; - -function getPageData($injector) { - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/indices/${$route.current.params.index}`; - const $http = $injector.get('$http'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/indices/:index/advanced', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchAdvancedIndexApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const indexName = $route.current.params.index; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.advanced.routeTitle', { - defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', - values: { - indexName, - }, - }), - telemetryPageViewTitle: 'elasticsearch_index_advanced', - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchAdvancedIndexApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LARGE_SHARD_SIZE], - filters: [ - { - shardIndex: $route.current.pathParams.index, - }, - ], - }, - }, - }); - - this.indexName = indexName; - - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.html deleted file mode 100644 index 84d90f184358d..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.js deleted file mode 100644 index c9efb622ff9d1..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/index/index.js +++ /dev/null @@ -1,148 +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. - */ - -/** - * Controller for single index detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -import { indicesByNodes } from '../../../components/elasticsearch/shard_allocation/transformers/indices_by_nodes'; -import { Index } from '../../../components/elasticsearch/index/index'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_LARGE_SHARD_SIZE, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../../common/constants'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { SetupModeRenderer } from '../../../components/renderers'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/indices/${$route.current.params.index}`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/indices/:index', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchIndexApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const indexName = $route.current.params.index; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.overview.routeTitle', { - defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', - values: { - indexName, - }, - }), - telemetryPageViewTitle: 'elasticsearch_index', - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.indices.overview.pageTitle', { - defaultMessage: 'Index: {indexName}', - values: { - indexName, - }, - }), - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchIndexApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LARGE_SHARD_SIZE], - filters: [ - { - shardIndex: $route.current.pathParams.index, - }, - ], - }, - }, - }); - - this.indexName = indexName; - const transformer = indicesByNodes(); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.shards) { - return; - } - - const shards = data.shards; - $scope.totalCount = shards.length; - $scope.showing = transformer(shards, data.nodes); - $scope.labels = labels.node; - if (shards.some((shard) => shard.state === 'UNASSIGNED')) { - $scope.labels = labels.indexWithUnassigned; - } else { - $scope.labels = labels.index; - } - - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.html deleted file mode 100644 index 84013078e0ef1..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.js deleted file mode 100644 index 5acff8be20dcf..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/indices/index.js +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { ElasticsearchIndices } from '../../../components'; -import template from './index.html'; -import { - CODE_PATH_ELASTICSEARCH, - ELASTICSEARCH_SYSTEM_ID, - RULE_LARGE_SHARD_SIZE, -} from '../../../../common/constants'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; - -uiRoutes.when('/elasticsearch/indices', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchIndices', - controller: class ElasticsearchIndicesController extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const features = $injector.get('features'); - - const { cluster_uuid: clusterUuid } = globalState; - $scope.cluster = find($route.current.locals.clusters, { cluster_uuid: clusterUuid }); - - let showSystemIndices = features.isEnabled('showSystemIndices', false); - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { - defaultMessage: 'Elasticsearch - Indices', - }), - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.indices.pageTitle', { - defaultMessage: 'Elasticsearch indices', - }), - storageKey: 'elasticsearch.indices', - apiUrlFn: () => - `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices?show_system_indices=${showSystemIndices}`, - reactNodeId: 'elasticsearchIndicesReact', - defaultData: {}, - $scope, - $injector, - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LARGE_SHARD_SIZE], - }, - }, - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - - // for binding - const toggleShowSystemIndices = (isChecked) => { - // flip the boolean - showSystemIndices = isChecked; - // preserve setting in localStorage - features.update('showSystemIndices', isChecked); - // update the page (resets pagination and sorting) - this.updateData(); - }; - - const renderComponent = () => { - const { clusterStatus, indices } = this.data; - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - }; - - this.onTableChangeRender = renderComponent; - - $scope.$watch( - () => this.data, - (data) => { - if (!data) { - return; - } - renderComponent(); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js deleted file mode 100644 index 39bd2686069de..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/get_page_data.js +++ /dev/null @@ -1,30 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/ml_jobs`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.html deleted file mode 100644 index 6fdae46b6b6ed..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js deleted file mode 100644 index d44b782f3994b..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/ml_jobs/index.js +++ /dev/null @@ -1,51 +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 { find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { CODE_PATH_ELASTICSEARCH, CODE_PATH_ML } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch/ml_jobs', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH, CODE_PATH_ML] }); - }, - pageData: getPageData, - }, - controllerAs: 'mlJobs', - controller: class MlJobsList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.mlJobs.routeTitle', { - defaultMessage: 'Elasticsearch - Machine Learning Jobs', - }), - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.mlJobs.pageTitle', { - defaultMessage: 'Elasticsearch machine learning jobs', - }), - storageKey: 'elasticsearch.mlJobs', - getPageData, - $scope, - $injector, - }); - - const $route = $injector.get('$route'); - this.data = $route.current.locals.pageData; - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - this.isCcrEnabled = Boolean($scope.cluster && $scope.cluster.isCcrEnabled); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.html deleted file mode 100644 index c79c4eed46bb7..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.html +++ /dev/null @@ -1,11 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js deleted file mode 100644 index dc0456178fbff..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/advanced/index.js +++ /dev/null @@ -1,135 +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. - */ - -/** - * Controller for Advanced Node Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { uiRoutes } from '../../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../../legacy_shims'; -import { AdvancedNode } from '../../../../components/elasticsearch/node/advanced'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_CPU_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MISSING_MONITORING_DATA, - RULE_DISK_USAGE, - RULE_MEMORY_USAGE, -} from '../../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/nodes/${$route.current.params.node}`; - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/elasticsearch/nodes/:node/advanced', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const nodeName = $route.current.params.node; - - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchAdvancedNodeApp', - telemetryPageViewTitle: 'elasticsearch_node_advanced', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [ - RULE_CPU_USAGE, - RULE_DISK_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MEMORY_USAGE, - RULE_MISSING_MONITORING_DATA, - ], - filters: [ - { - nodeUuid: nodeName, - }, - ], - }, - }, - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.elasticsearch.node.advanced.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes - {nodeSummaryName} - Advanced', - values: { - nodeSummaryName: get(data, 'nodeSummary.name'), - }, - }) - ); - - this.setPageTitle( - i18n.translate('xpack.monitoring.elasticsearch.node.overview.pageTitle', { - defaultMessage: 'Elasticsearch node: {node}', - values: { - node: get(data, 'nodeSummary.name'), - }, - }) - ); - - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js deleted file mode 100644 index 1d8bc3f3efa32..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/get_page_data.js +++ /dev/null @@ -1,36 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch/nodes/${$route.current.params.node}`; - const features = $injector.get('features'); - const showSystemIndices = features.isEnabled('showSystemIndices', false); - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - showSystemIndices, - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.html deleted file mode 100644 index 1c3b32728cecd..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.html +++ /dev/null @@ -1,11 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js deleted file mode 100644 index 3ec10aa9d4a4c..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/node/index.js +++ /dev/null @@ -1,155 +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. - */ - -/** - * Controller for Node Detail - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { get, partial } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { Node } from '../../../components/elasticsearch/node/node'; -import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; -import { nodesByIndices } from '../../../components/elasticsearch/shard_allocation/transformers/nodes_by_indices'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { - CODE_PATH_ELASTICSEARCH, - RULE_CPU_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MISSING_MONITORING_DATA, - RULE_DISK_USAGE, - RULE_MEMORY_USAGE, - ELASTICSEARCH_SYSTEM_ID, -} from '../../../../common/constants'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; - -uiRoutes.when('/elasticsearch/nodes/:node', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringElasticsearchNodeApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const nodeName = $route.current.params.node; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.node.overview.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Overview', - values: { - nodeName, - }, - }), - telemetryPageViewTitle: 'elasticsearch_node', - defaultData: {}, - getPageData, - reactNodeId: 'monitoringElasticsearchNodeApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [ - RULE_CPU_USAGE, - RULE_DISK_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MEMORY_USAGE, - RULE_MISSING_MONITORING_DATA, - ], - filters: [ - { - nodeUuid: nodeName, - }, - ], - }, - }, - }); - - this.nodeName = nodeName; - - const features = $injector.get('features'); - const callPageData = partial(getPageData, $injector); - // show/hide system indices in shard allocation view - $scope.showSystemIndices = features.isEnabled('showSystemIndices', false); - $scope.toggleShowSystemIndices = (isChecked) => { - $scope.showSystemIndices = isChecked; - // preserve setting in localStorage - features.update('showSystemIndices', isChecked); - // update the page - callPageData().then((data) => (this.data = data)); - }; - - const transformer = nodesByIndices(); - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.shards) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.elasticsearch.node.overview.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Overview', - values: { - nodeName: get(data, 'nodeSummary.name'), - }, - }) - ); - - this.setPageTitle( - i18n.translate('xpack.monitoring.elasticsearch.node.overview.pageTitle', { - defaultMessage: 'Elasticsearch node: {node}', - values: { - node: get(data, 'nodeSummary.name'), - }, - }) - ); - - const shards = data.shards; - $scope.totalCount = shards.length; - $scope.showing = transformer(shards, data.nodes); - $scope.labels = labels.node; - - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.html deleted file mode 100644 index 95a483a59f20c..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js deleted file mode 100644 index 5bc546e8590ad..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/nodes/index.js +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { Legacy } from '../../../legacy_shims'; -import template from './index.html'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { ElasticsearchNodes } from '../../../components'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { - ELASTICSEARCH_SYSTEM_ID, - CODE_PATH_ELASTICSEARCH, - RULE_CPU_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MISSING_MONITORING_DATA, - RULE_DISK_USAGE, - RULE_MEMORY_USAGE, -} from '../../../../common/constants'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; - -uiRoutes.when('/elasticsearch/nodes', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchNodes', - controller: class ElasticsearchNodesController extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const showCgroupMetricsElasticsearch = $injector.get('showCgroupMetricsElasticsearch'); - - $scope.cluster = - find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }) || {}; - - const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // to fix eslint - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const timeBounds = Legacy.shims.timefilter.getBounds(); - - const getNodes = (clusterUuid = globalState.cluster_uuid) => - $http.post(`../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes`, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }); - - const promise = globalState.cluster_uuid - ? getNodes() - : new Promise((resolve) => resolve({ data: {} })); - return promise - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); - }; - - super({ - title: i18n.translate('xpack.monitoring.elasticsearch.nodes.routeTitle', { - defaultMessage: 'Elasticsearch - Nodes', - }), - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.nodes.pageTitle', { - defaultMessage: 'Elasticsearch nodes', - }), - storageKey: 'elasticsearch.nodes', - reactNodeId: 'elasticsearchNodesReact', - defaultData: {}, - getPageData, - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [ - RULE_CPU_USAGE, - RULE_DISK_USAGE, - RULE_THREAD_POOL_SEARCH_REJECTIONS, - RULE_THREAD_POOL_WRITE_REJECTIONS, - RULE_MEMORY_USAGE, - RULE_MISSING_MONITORING_DATA, - ], - }, - }, - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - - $scope.$watch( - () => this.data, - (data) => { - if (!data) { - return; - } - - const { clusterStatus, nodes, totalNodeCount } = data; - const pagination = { - ...this.pagination, - totalItemCount: totalNodeCount, - }; - - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/controller.js b/x-pack/plugins/monitoring/public/views/elasticsearch/overview/controller.js deleted file mode 100644 index f39033fe7014d..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/controller.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { MonitoringViewBaseController } from '../../'; -import { ElasticsearchOverview } from '../../../components'; - -export class ElasticsearchOverviewController extends MonitoringViewBaseController { - constructor($injector, $scope) { - // breadcrumbs + page title - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - super({ - title: 'Elasticsearch', - pageTitle: i18n.translate('xpack.monitoring.elasticsearch.overview.pageTitle', { - defaultMessage: 'Elasticsearch overview', - }), - api: `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/elasticsearch`, - defaultData: { - clusterStatus: { status: '' }, - metrics: null, - shardActivity: null, - }, - reactNodeId: 'elasticsearchOverviewReact', - $scope, - $injector, - }); - - this.isCcrEnabled = $scope.cluster.isCcrEnabled; - this.showShardActivityHistory = false; - this.toggleShardActivityHistory = () => { - this.showShardActivityHistory = !this.showShardActivityHistory; - $scope.$evalAsync(() => { - this.renderReact(this.data, $scope.cluster); - }); - }; - - this.initScope($scope); - } - - initScope($scope) { - $scope.$watch( - () => this.data, - (data) => { - this.renderReact(data, $scope.cluster); - } - ); - - // HACK to force table to re-render even if data hasn't changed. This - // happens when the data remains empty after turning on showHistory. The - // button toggle needs to update the "no data" message based on the value of showHistory - $scope.$watch( - () => this.showShardActivityHistory, - () => { - const { data } = this; - const dataWithShardActivityLoading = { ...data, shardActivity: null }; - // force shard activity to rerender by manipulating and then re-setting its data prop - this.renderReact(dataWithShardActivityLoading, $scope.cluster); - this.renderReact(data, $scope.cluster); - } - ); - } - - filterShardActivityData(shardActivity) { - return shardActivity.filter((row) => { - return this.showShardActivityHistory || row.stage !== 'DONE'; - }); - } - - renderReact(data, cluster) { - // All data needs to originate in this view, and get passed as a prop to the components, for statelessness - const { clusterStatus, metrics, shardActivity, logs } = data || {}; - const shardActivityData = shardActivity && this.filterShardActivityData(shardActivity); // no filter on data = null - const component = ( - - ); - - super.renderReact(component); - } -} diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.html b/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.html deleted file mode 100644 index 127c48add5e8d..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.js b/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.js deleted file mode 100644 index cc507934dd767..0000000000000 --- a/x-pack/plugins/monitoring/public/views/elasticsearch/overview/index.js +++ /dev/null @@ -1,24 +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 { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { ElasticsearchOverviewController } from './controller'; -import { CODE_PATH_ELASTICSEARCH } from '../../../../common/constants'; - -uiRoutes.when('/elasticsearch', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_ELASTICSEARCH] }); - }, - }, - controllerAs: 'elasticsearchOverview', - controller: ElasticsearchOverviewController, -}); diff --git a/x-pack/plugins/monitoring/public/views/index.js b/x-pack/plugins/monitoring/public/views/index.js deleted file mode 100644 index 8cfb8f35e68ba..0000000000000 --- a/x-pack/plugins/monitoring/public/views/index.js +++ /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 { MonitoringViewBaseController } from './base_controller'; -export { MonitoringViewBaseTableController } from './base_table_controller'; -export { MonitoringViewBaseEuiTableController } from './base_eui_table_controller'; diff --git a/x-pack/plugins/monitoring/public/views/kibana/instance/index.html b/x-pack/plugins/monitoring/public/views/kibana/instance/index.html deleted file mode 100644 index 8bb17839683a8..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/instance/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/kibana/instance/index.js b/x-pack/plugins/monitoring/public/views/kibana/instance/index.js deleted file mode 100644 index a71289b084516..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/instance/index.js +++ /dev/null @@ -1,168 +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. - */ - -/* - * Kibana Instance - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { get } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, - EuiPanel, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { DetailStatus } from '../../../components/kibana/detail_status'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_KIBANA, RULE_KIBANA_VERSION_MISMATCH } from '../../../../common/constants'; -import { AlertsCallout } from '../../../alerts/callout'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana/${$route.current.params.uuid}`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/kibana/instances/:uuid', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringKibanaInstanceApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: `Kibana - ${get($scope.pageData, 'kibanaSummary.name')}`, - telemetryPageViewTitle: 'kibana_instance', - defaultData: {}, - getPageData, - reactNodeId: 'monitoringKibanaInstanceApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], - }, - }, - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.metrics) { - return; - } - this.setTitle(`Kibana - ${get(data, 'kibanaSummary.name')}`); - this.setPageTitle( - i18n.translate('xpack.monitoring.kibana.instance.pageTitle', { - defaultMessage: 'Kibana instance: {instance}', - values: { - instance: get($scope.pageData, 'kibanaSummary.name'), - }, - }) - ); - - this.renderReact( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/kibana/instances/get_page_data.js b/x-pack/plugins/monitoring/public/views/kibana/instances/get_page_data.js deleted file mode 100644 index 82c49ee0ebb13..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/instances/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana/instances`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/kibana/instances/index.html b/x-pack/plugins/monitoring/public/views/kibana/instances/index.html deleted file mode 100644 index 8e1639a2323a5..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/instances/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/kibana/instances/index.js b/x-pack/plugins/monitoring/public/views/kibana/instances/index.js deleted file mode 100644 index 2601a366e6843..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/instances/index.js +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { KibanaInstances } from '../../../components/kibana/instances'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { - KIBANA_SYSTEM_ID, - CODE_PATH_KIBANA, - RULE_KIBANA_VERSION_MISMATCH, -} from '../../../../common/constants'; - -uiRoutes.when('/kibana/instances', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'kibanas', - controller: class KibanaInstancesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.kibana.instances.routeTitle', { - defaultMessage: 'Kibana - Instances', - }), - pageTitle: i18n.translate('xpack.monitoring.kibana.instances.pageTitle', { - defaultMessage: 'Kibana instances', - }), - storageKey: 'kibana.instances', - getPageData, - reactNodeId: 'monitoringKibanaInstancesApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], - }, - }, - }); - - const renderReact = () => { - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - }; - - this.onTableChangeRender = renderReact; - - $scope.$watch( - () => this.data, - (data) => { - if (!data) { - return; - } - - renderReact(); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/kibana/overview/index.html b/x-pack/plugins/monitoring/public/views/kibana/overview/index.html deleted file mode 100644 index 5b131e113dfa4..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/overview/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/kibana/overview/index.js b/x-pack/plugins/monitoring/public/views/kibana/overview/index.js deleted file mode 100644 index ad59265a98531..0000000000000 --- a/x-pack/plugins/monitoring/public/views/kibana/overview/index.js +++ /dev/null @@ -1,117 +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. - */ - -/** - * Kibana Overview - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { ClusterStatus } from '../../../components/kibana/cluster_status'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_KIBANA } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/kibana`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/kibana', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_KIBANA] }); - }, - pageData: getPageData, - }, - controllerAs: 'monitoringKibanaOverviewApp', - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: `Kibana`, - pageTitle: i18n.translate('xpack.monitoring.kibana.overview.pageTitle', { - defaultMessage: 'Kibana overview', - }), - defaultData: {}, - getPageData, - reactNodeId: 'monitoringKibanaOverviewApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.clusterStatus) { - return; - } - - this.renderReact( - - - - - - - - - - - - - - - - - - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/license/controller.js b/x-pack/plugins/monitoring/public/views/license/controller.js deleted file mode 100644 index 297edf6481a55..0000000000000 --- a/x-pack/plugins/monitoring/public/views/license/controller.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get, find } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; -import { Legacy } from '../../legacy_shims'; -import { formatDateTimeLocal } from '../../../common/formatting'; -import { BASE_PATH as MANAGEMENT_BASE_PATH } from '../../../../../plugins/license_management/common/constants'; -import { License } from '../../components'; - -const REACT_NODE_ID = 'licenseReact'; - -export class LicenseViewController { - constructor($injector, $scope) { - Legacy.shims.timefilter.disableTimeRangeSelector(); - Legacy.shims.timefilter.disableAutoRefreshSelector(); - - $scope.$on('$destroy', () => { - unmountComponentAtNode(document.getElementById(REACT_NODE_ID)); - }); - - this.init($injector, $scope, i18n); - } - - init($injector, $scope) { - const globalState = $injector.get('globalState'); - const title = $injector.get('title'); - const $route = $injector.get('$route'); - - const cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - $scope.cluster = cluster; - const routeTitle = i18n.translate('xpack.monitoring.license.licenseRouteTitle', { - defaultMessage: 'License', - }); - title($scope.cluster, routeTitle); - - this.license = cluster.license; - this.isExpired = Date.now() > get(cluster, 'license.expiry_date_in_millis'); - this.isPrimaryCluster = cluster.isPrimary; - - const basePath = Legacy.shims.getBasePath(); - this.uploadLicensePath = basePath + '/app/kibana#' + MANAGEMENT_BASE_PATH + 'upload_license'; - - this.renderReact($scope); - } - - renderReact($scope) { - const injector = Legacy.shims.getAngularInjector(); - const timezone = injector.get('config').get('dateFormat:tz'); - $scope.$evalAsync(() => { - const { isPrimaryCluster, license, isExpired, uploadLicensePath } = this; - let expiryDate = license.expiry_date_in_millis; - if (license.expiry_date_in_millis !== undefined) { - expiryDate = formatDateTimeLocal(license.expiry_date_in_millis, timezone); - } - - // Mount the React component to the template - render( - , - document.getElementById(REACT_NODE_ID) - ); - }); - } -} diff --git a/x-pack/plugins/monitoring/public/views/license/index.html b/x-pack/plugins/monitoring/public/views/license/index.html deleted file mode 100644 index 7fb9c69941004..0000000000000 --- a/x-pack/plugins/monitoring/public/views/license/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/license/index.js b/x-pack/plugins/monitoring/public/views/license/index.js deleted file mode 100644 index 0ffb953268690..0000000000000 --- a/x-pack/plugins/monitoring/public/views/license/index.js +++ /dev/null @@ -1,24 +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 { uiRoutes } from '../../angular/helpers/routes'; -import { routeInitProvider } from '../../lib/route_init'; -import template from './index.html'; -import { LicenseViewController } from './controller'; -import { CODE_PATH_LICENSE } from '../../../common/constants'; - -uiRoutes.when('/license', { - template, - resolve: { - clusters: (Private) => { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LICENSE] }); - }, - }, - controllerAs: 'licenseView', - controller: LicenseViewController, -}); diff --git a/x-pack/plugins/monitoring/public/views/loading/index.html b/x-pack/plugins/monitoring/public/views/loading/index.html deleted file mode 100644 index 9a5971a65bc39..0000000000000 --- a/x-pack/plugins/monitoring/public/views/loading/index.html +++ /dev/null @@ -1,5 +0,0 @@ - -
-
-
-
diff --git a/x-pack/plugins/monitoring/public/views/loading/index.js b/x-pack/plugins/monitoring/public/views/loading/index.js deleted file mode 100644 index 6406b9e6364f0..0000000000000 --- a/x-pack/plugins/monitoring/public/views/loading/index.js +++ /dev/null @@ -1,78 +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. - */ - -/** - * Controller for single index detail - */ -import React from 'react'; -import { render } from 'react-dom'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../angular/helpers/routes'; -import { routeInitProvider } from '../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../legacy_shims'; -import { CODE_PATH_ELASTICSEARCH } from '../../../common/constants'; -import { PageLoading } from '../../components'; -import { ajaxErrorHandlersProvider } from '../../lib/ajax_error_handler'; - -const CODE_PATHS = [CODE_PATH_ELASTICSEARCH]; -uiRoutes.when('/loading', { - template, - controllerAs: 'monitoringLoading', - controller: class { - constructor($injector, $scope) { - const Private = $injector.get('Private'); - const titleService = $injector.get('title'); - titleService( - $scope.cluster, - i18n.translate('xpack.monitoring.loading.pageTitle', { - defaultMessage: 'Loading', - }) - ); - - this.init = () => { - const reactNodeId = 'monitoringLoadingReact'; - const renderElement = document.getElementById(reactNodeId); - if (!renderElement) { - console.warn(`"#${reactNodeId}" element has not been added to the DOM yet`); - return; - } - const I18nContext = Legacy.shims.I18nContext; - render( - - - , - renderElement - ); - }; - - const routeInit = Private(routeInitProvider); - routeInit({ codePaths: CODE_PATHS, fetchAllClusters: true, unsetGlobalState: true }) - .then((clusters) => { - if (!clusters || !clusters.length) { - window.location.hash = '#/no-data'; - $scope.$apply(); - return; - } - if (clusters.length === 1) { - // Bypass the cluster listing if there is just 1 cluster - window.history.replaceState(null, null, '#/overview'); - $scope.$apply(); - return; - } - - window.history.replaceState(null, null, '#/home'); - $scope.$apply(); - }) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return $scope.$apply(() => ajaxErrorHandlers(err)); - }); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.html b/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.html deleted file mode 100644 index 63f51809fd7e7..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.js b/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.js deleted file mode 100644 index 9acfd81d186fd..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/advanced/index.js +++ /dev/null @@ -1,149 +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. - */ - -/* - * Logstash Node Advanced View - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../../legacy_shims'; -import { MonitoringViewBaseController } from '../../../base_controller'; -import { DetailStatus } from '../../../../components/logstash/detail_status'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../../components/chart'; -import { - CODE_PATH_LOGSTASH, - RULE_LOGSTASH_VERSION_MISMATCH, -} from '../../../../../common/constants'; -import { AlertsCallout } from '../../../../alerts/callout'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const $route = $injector.get('$route'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${$route.current.params.uuid}`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: true, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/node/:uuid/advanced', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodeAdvancedApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], - }, - }, - telemetryPageViewTitle: 'logstash_node_advanced', - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.advanced.routeTitle', { - defaultMessage: 'Logstash - {nodeName} - Advanced', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - this.setPageTitle( - i18n.translate('xpack.monitoring.logstash.node.advanced.pageTitle', { - defaultMessage: 'Logstash node: {nodeName}', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const metricsToShow = [ - data.metrics.logstash_node_cpu_utilization, - data.metrics.logstash_queue_events_count, - data.metrics.logstash_node_cgroup_cpu, - data.metrics.logstash_pipeline_queue_size, - data.metrics.logstash_node_cgroup_stats, - ]; - - this.renderReact( - - - - - - - - - - {metricsToShow.map((metric, index) => ( - - - - - ))} - - - - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/index.html b/x-pack/plugins/monitoring/public/views/logstash/node/index.html deleted file mode 100644 index 062c830dd8b7a..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/index.html +++ /dev/null @@ -1,9 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/index.js b/x-pack/plugins/monitoring/public/views/logstash/node/index.js deleted file mode 100644 index b23875ba1a3bb..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/index.js +++ /dev/null @@ -1,147 +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. - */ - -/* - * Logstash Node - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { DetailStatus } from '../../../components/logstash/detail_status'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiPanel, - EuiSpacer, - EuiFlexGrid, - EuiFlexItem, -} from '@elastic/eui'; -import { MonitoringTimeseriesContainer } from '../../../components/chart'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_LOGSTASH, RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; -import { AlertsCallout } from '../../../alerts/callout'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const $route = $injector.get('$route'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${$route.current.params.uuid}`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - is_advanced: false, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/node/:uuid', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodeApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], - }, - }, - telemetryPageViewTitle: 'logstash_node', - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.routeTitle', { - defaultMessage: 'Logstash - {nodeName}', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - this.setPageTitle( - i18n.translate('xpack.monitoring.logstash.node.pageTitle', { - defaultMessage: 'Logstash node: {nodeName}', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const metricsToShow = [ - data.metrics.logstash_events_input_rate, - data.metrics.logstash_jvm_usage, - data.metrics.logstash_events_output_rate, - data.metrics.logstash_node_cpu_metric, - data.metrics.logstash_events_latency, - data.metrics.logstash_os_load, - ]; - - this.renderReact( - - - - - - - - - - {metricsToShow.map((metric, index) => ( - - - - - ))} - - - - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.html b/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.html deleted file mode 100644 index cae3a169bfd5a..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.html +++ /dev/null @@ -1,8 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.js b/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.js deleted file mode 100644 index 0d5105696102a..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/node/pipelines/index.js +++ /dev/null @@ -1,135 +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. - */ - -/* - * Logstash Node Pipelines Listing - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../../lib/route_init'; -import { isPipelineMonitoringSupportedInVersion } from '../../../../lib/logstash/pipelines'; -import template from './index.html'; -import { Legacy } from '../../../../legacy_shims'; -import { MonitoringViewBaseEuiTableController } from '../../../'; -import { PipelineListing } from '../../../../components/logstash/pipeline_listing/pipeline_listing'; -import { DetailStatus } from '../../../../components/logstash/detail_status'; -import { CODE_PATH_LOGSTASH } from '../../../../../common/constants'; - -const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // fixing eslint - const $route = $injector.get('$route'); - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const Private = $injector.get('Private'); - - const logstashUuid = $route.current.params.uuid; - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/node/${logstashUuid}/pipelines`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }) - .then((response) => response.data) - .catch((err) => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -}; - -function makeUpgradeMessage(logstashVersion) { - if (isPipelineMonitoringSupportedInVersion(logstashVersion)) { - return null; - } - - return i18n.translate('xpack.monitoring.logstash.node.pipelines.notAvailableDescription', { - defaultMessage: - 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher. This node is running version {logstashVersion}.', - values: { - logstashVersion, - }, - }); -} - -uiRoutes.when('/logstash/node/:uuid/pipelines', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - }, - controller: class extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - const config = $injector.get('config'); - - super({ - defaultData: {}, - getPageData, - reactNodeId: 'monitoringLogstashNodePipelinesApp', - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request - telemetryPageViewTitle: 'logstash_node_pipelines', - }); - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.nodeSummary) { - return; - } - - this.setTitle( - i18n.translate('xpack.monitoring.logstash.node.pipelines.routeTitle', { - defaultMessage: 'Logstash - {nodeName} - Pipelines', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - this.setPageTitle( - i18n.translate('xpack.monitoring.logstash.node.pipelines.pageTitle', { - defaultMessage: 'Logstash node pipelines: {nodeName}', - values: { - nodeName: data.nodeSummary.name, - }, - }) - ); - - const pagination = { - ...this.pagination, - totalItemCount: data.totalPipelineCount, - }; - - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/nodes/get_page_data.js b/x-pack/plugins/monitoring/public/views/logstash/nodes/get_page_data.js deleted file mode 100644 index 4c9167a47b0d7..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/nodes/get_page_data.js +++ /dev/null @@ -1,31 +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 { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { Legacy } from '../../../legacy_shims'; - -export function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/nodes`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} diff --git a/x-pack/plugins/monitoring/public/views/logstash/nodes/index.html b/x-pack/plugins/monitoring/public/views/logstash/nodes/index.html deleted file mode 100644 index 6da00b1c771b8..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/nodes/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/nodes/index.js b/x-pack/plugins/monitoring/public/views/logstash/nodes/index.js deleted file mode 100644 index 56b5d0ec6c82a..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/nodes/index.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { routeInitProvider } from '../../../lib/route_init'; -import { MonitoringViewBaseEuiTableController } from '../../'; -import { getPageData } from './get_page_data'; -import template from './index.html'; -import { Listing } from '../../../components/logstash/listing'; -import { SetupModeRenderer } from '../../../components/renderers'; -import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { - CODE_PATH_LOGSTASH, - LOGSTASH_SYSTEM_ID, - RULE_LOGSTASH_VERSION_MISMATCH, -} from '../../../../common/constants'; - -uiRoutes.when('/logstash/nodes', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controllerAs: 'lsNodes', - controller: class LsNodesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.logstash.nodes.routeTitle', { - defaultMessage: 'Logstash - Nodes', - }), - pageTitle: i18n.translate('xpack.monitoring.logstash.nodes.pageTitle', { - defaultMessage: 'Logstash nodes', - }), - storageKey: 'logstash.nodes', - getPageData, - reactNodeId: 'monitoringLogstashNodesApp', - $scope, - $injector, - alerts: { - shouldFetch: true, - options: { - alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], - }, - }, - }); - - const renderComponent = () => { - this.renderReact( - ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> - ); - }; - - this.onTableChangeRender = renderComponent; - - $scope.$watch( - () => this.data, - () => renderComponent() - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/overview/index.html b/x-pack/plugins/monitoring/public/views/logstash/overview/index.html deleted file mode 100644 index 088aa35892bbe..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/overview/index.html +++ /dev/null @@ -1,3 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/overview/index.js b/x-pack/plugins/monitoring/public/views/logstash/overview/index.js deleted file mode 100644 index b5e8ecbefc532..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/overview/index.js +++ /dev/null @@ -1,81 +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. - */ - -/** - * Logstash Overview - */ -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { Overview } from '../../../components/logstash/overview'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; - -function getPageData($injector) { - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - }) - .then((response) => response.data) - .catch((err) => { - const Private = $injector.get('Private'); - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash', { - template, - resolve: { - clusters: function (Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - super({ - title: 'Logstash', - pageTitle: i18n.translate('xpack.monitoring.logstash.overview.pageTitle', { - defaultMessage: 'Logstash overview', - }), - getPageData, - reactNodeId: 'monitoringLogstashOverviewApp', - $scope, - $injector, - }); - - $scope.$watch( - () => this.data, - (data) => { - this.renderReact( - - ); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.html b/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.html deleted file mode 100644 index afd1d994f1e9c..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.html +++ /dev/null @@ -1,12 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.js b/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.js deleted file mode 100644 index dd7bcc8436358..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/pipeline/index.js +++ /dev/null @@ -1,181 +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. - */ - -/* - * Logstash Node Pipeline View - */ -import React from 'react'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import moment from 'moment'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import { CALCULATE_DURATION_SINCE, CODE_PATH_LOGSTASH } from '../../../../common/constants'; -import { formatTimestampToDuration } from '../../../../common/format_timestamp_to_duration'; -import template from './index.html'; -import { i18n } from '@kbn/i18n'; -import { List } from '../../../components/logstash/pipeline_viewer/models/list'; -import { PipelineState } from '../../../components/logstash/pipeline_viewer/models/pipeline_state'; -import { PipelineViewer } from '../../../components/logstash/pipeline_viewer'; -import { Pipeline } from '../../../components/logstash/pipeline_viewer/models/pipeline'; -import { vertexFactory } from '../../../components/logstash/pipeline_viewer/models/graph/vertex_factory'; -import { MonitoringViewBaseController } from '../../base_controller'; -import { EuiPageBody, EuiPage, EuiPageContent } from '@elastic/eui'; - -let previousPipelineHash = undefined; -let detailVertexId = undefined; - -function getPageData($injector) { - const $route = $injector.get('$route'); - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const minIntervalSeconds = $injector.get('minIntervalSeconds'); - const Private = $injector.get('Private'); - - const { ccs, cluster_uuid: clusterUuid } = globalState; - const pipelineId = $route.current.params.id; - const pipelineHash = $route.current.params.hash || ''; - - // Pipeline version was changed, so clear out detailVertexId since that vertex won't - // exist in the updated pipeline version - if (pipelineHash !== previousPipelineHash) { - previousPipelineHash = pipelineHash; - detailVertexId = undefined; - } - - const url = pipelineHash - ? `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}/${pipelineHash}` - : `../api/monitoring/v1/clusters/${clusterUuid}/logstash/pipeline/${pipelineId}`; - return $http - .post(url, { - ccs, - detailVertexId, - }) - .then((response) => response.data) - .then((data) => { - data.versions = data.versions.map((version) => { - const relativeFirstSeen = formatTimestampToDuration( - version.firstSeen, - CALCULATE_DURATION_SINCE - ); - const relativeLastSeen = formatTimestampToDuration( - version.lastSeen, - CALCULATE_DURATION_SINCE - ); - - const fudgeFactorSeconds = 2 * minIntervalSeconds; - const isLastSeenCloseToNow = Date.now() - version.lastSeen <= fudgeFactorSeconds * 1000; - - return { - ...version, - relativeFirstSeen: i18n.translate( - 'xpack.monitoring.logstash.pipeline.relativeFirstSeenAgoLabel', - { - defaultMessage: '{relativeFirstSeen} ago', - values: { relativeFirstSeen }, - } - ), - relativeLastSeen: isLastSeenCloseToNow - ? i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenNowLabel', { - defaultMessage: 'now', - }) - : i18n.translate('xpack.monitoring.logstash.pipeline.relativeLastSeenAgoLabel', { - defaultMessage: 'until {relativeLastSeen} ago', - values: { relativeLastSeen }, - }), - }; - }); - - return data; - }) - .catch((err) => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -} - -uiRoutes.when('/logstash/pipelines/:id/:hash?', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - pageData: getPageData, - }, - controller: class extends MonitoringViewBaseController { - constructor($injector, $scope) { - const config = $injector.get('config'); - const dateFormat = config.get('dateFormat'); - - super({ - title: i18n.translate('xpack.monitoring.logstash.pipeline.routeTitle', { - defaultMessage: 'Logstash - Pipeline', - }), - storageKey: 'logstash.pipelines', - getPageData, - reactNodeId: 'monitoringLogstashPipelineApp', - $scope, - options: { - enableTimeFilter: false, - }, - $injector, - }); - - const timeseriesTooltipXValueFormatter = (xValue) => moment(xValue).format(dateFormat); - - const setDetailVertexId = (vertex) => { - if (!vertex) { - detailVertexId = undefined; - } else { - detailVertexId = vertex.id; - } - - return this.updateData(); - }; - - $scope.$watch( - () => this.data, - (data) => { - if (!data || !data.pipeline) { - return; - } - this.setPageTitle( - i18n.translate('xpack.monitoring.logstash.pipeline.pageTitle', { - defaultMessage: 'Logstash pipeline: {pipeline}', - values: { - pipeline: data.pipeline.id, - }, - }) - ); - this.pipelineState = new PipelineState(data.pipeline); - this.detailVertex = data.vertex ? vertexFactory(null, data.vertex) : null; - this.renderReact( - - - - - - - - ); - } - ); - - $scope.$on('$destroy', () => { - previousPipelineHash = undefined; - detailVertexId = undefined; - }); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.html b/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.html deleted file mode 100644 index bef8a7a4737f3..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.html +++ /dev/null @@ -1,7 +0,0 @@ - -
-
diff --git a/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.js b/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.js deleted file mode 100644 index f3121687f17db..0000000000000 --- a/x-pack/plugins/monitoring/public/views/logstash/pipelines/index.js +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { uiRoutes } from '../../../angular/helpers/routes'; -import { ajaxErrorHandlersProvider } from '../../../lib/ajax_error_handler'; -import { routeInitProvider } from '../../../lib/route_init'; -import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; -import template from './index.html'; -import { Legacy } from '../../../legacy_shims'; -import { PipelineListing } from '../../../components/logstash/pipeline_listing/pipeline_listing'; -import { MonitoringViewBaseEuiTableController } from '../..'; -import { CODE_PATH_LOGSTASH } from '../../../../common/constants'; - -/* - * Logstash Pipelines Listing page - */ - -const getPageData = ($injector, _api = undefined, routeOptions = {}) => { - _api; // to fix eslint - const $http = $injector.get('$http'); - const globalState = $injector.get('globalState'); - const Private = $injector.get('Private'); - - const url = `../api/monitoring/v1/clusters/${globalState.cluster_uuid}/logstash/pipelines`; - const timeBounds = Legacy.shims.timefilter.getBounds(); - - return $http - .post(url, { - ccs: globalState.ccs, - timeRange: { - min: timeBounds.min.toISOString(), - max: timeBounds.max.toISOString(), - }, - ...routeOptions, - }) - .then((response) => response.data) - .catch((err) => { - const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); - return ajaxErrorHandlers(err); - }); -}; - -function makeUpgradeMessage(logstashVersions) { - if ( - !Array.isArray(logstashVersions) || - logstashVersions.length === 0 || - logstashVersions.some(isPipelineMonitoringSupportedInVersion) - ) { - return null; - } - - return 'Pipeline monitoring is only available in Logstash version 6.0.0 or higher.'; -} - -uiRoutes.when('/logstash/pipelines', { - template, - resolve: { - clusters(Private) { - const routeInit = Private(routeInitProvider); - return routeInit({ codePaths: [CODE_PATH_LOGSTASH] }); - }, - }, - controller: class LogstashPipelinesList extends MonitoringViewBaseEuiTableController { - constructor($injector, $scope) { - super({ - title: i18n.translate('xpack.monitoring.logstash.pipelines.routeTitle', { - defaultMessage: 'Logstash Pipelines', - }), - pageTitle: i18n.translate('xpack.monitoring.logstash.pipelines.pageTitle', { - defaultMessage: 'Logstash pipelines', - }), - storageKey: 'logstash.pipelines', - getPageData, - reactNodeId: 'monitoringLogstashPipelinesApp', - $scope, - $injector, - fetchDataImmediately: false, // We want to apply pagination before sending the first request - }); - - const $route = $injector.get('$route'); - const config = $injector.get('config'); - this.data = $route.current.locals.pageData; - const globalState = $injector.get('globalState'); - $scope.cluster = find($route.current.locals.clusters, { - cluster_uuid: globalState.cluster_uuid, - }); - - const renderReact = (pageData) => { - if (!pageData) { - return; - } - - const upgradeMessage = pageData - ? makeUpgradeMessage(pageData.clusterStatus.versions, i18n) - : null; - - const pagination = { - ...this.pagination, - totalItemCount: pageData.totalPipelineCount, - }; - - super.renderReact( - this.onBrush({ xaxis })} - stats={pageData.clusterStatus} - data={pageData.pipelines} - {...this.getPaginationTableProps(pagination)} - upgradeMessage={upgradeMessage} - dateFormat={config.get('dateFormat')} - /> - ); - }; - - $scope.$watch( - () => this.data, - (pageData) => { - renderReact(pageData); - } - ); - } - }, -}); diff --git a/x-pack/plugins/monitoring/public/views/no_data/controller.js b/x-pack/plugins/monitoring/public/views/no_data/controller.js deleted file mode 100644 index 4a6a73dfb2010..0000000000000 --- a/x-pack/plugins/monitoring/public/views/no_data/controller.js +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { - ClusterSettingsChecker, - NodeSettingsChecker, - Enabler, - startChecks, -} from '../../lib/elasticsearch_settings'; -import { ModelUpdater } from './model_updater'; -import { NoData } from '../../components'; -import { CODE_PATH_LICENSE } from '../../../common/constants'; -import { MonitoringViewBaseController } from '../base_controller'; -import { i18n } from '@kbn/i18n'; -import { Legacy } from '../../legacy_shims'; - -export class NoDataController extends MonitoringViewBaseController { - constructor($injector, $scope) { - window.injectorThree = $injector; - const monitoringClusters = $injector.get('monitoringClusters'); - const $http = $injector.get('$http'); - const checkers = [new ClusterSettingsChecker($http), new NodeSettingsChecker($http)]; - - const getData = async () => { - let catchReason; - try { - const monitoringClustersData = await monitoringClusters(undefined, undefined, [ - CODE_PATH_LICENSE, - ]); - if (monitoringClustersData && monitoringClustersData.length) { - window.history.replaceState(null, null, '#/home'); - return monitoringClustersData; - } - } catch (err) { - if (err && err.status === 503) { - catchReason = { - property: 'custom', - message: err.data.message, - }; - } - } - - this.errors.length = 0; - if (catchReason) { - this.reason = catchReason; - } else if (!this.isCollectionEnabledUpdating && !this.isCollectionIntervalUpdating) { - /** - * `no-use-before-define` is fine here, since getData is an async function. - * Needs to be done this way, since there is no `this` before super is executed - * */ - await startChecks(checkers, updateModel); // eslint-disable-line no-use-before-define - } - }; - - super({ - title: i18n.translate('xpack.monitoring.noData.routeTitle', { - defaultMessage: 'Setup Monitoring', - }), - getPageData: async () => await getData(), - reactNodeId: 'noDataReact', - $scope, - $injector, - }); - Object.assign(this, this.getDefaultModel()); - - //Need to set updateModel after super since there is no `this` otherwise - const { updateModel } = new ModelUpdater($scope, this); - const enabler = new Enabler($http, updateModel); - $scope.$watch( - () => this, - () => { - if (this.isCollectionEnabledUpdated && !this.reason) { - return; - } - this.render(enabler); - }, - true - ); - } - - getDefaultModel() { - return { - errors: [], // errors can happen from trying to check or set ES settings - checkMessage: null, // message to show while waiting for api response - isLoading: true, // flag for in-progress state of checking for no data reason - isCollectionEnabledUpdating: false, // flags to indicate whether to show a spinner while waiting for ajax - isCollectionEnabledUpdated: false, - isCollectionIntervalUpdating: false, - isCollectionIntervalUpdated: false, - }; - } - - render(enabler) { - const props = this; - this.renderReact(); - } -} diff --git a/x-pack/plugins/monitoring/public/views/no_data/index.html b/x-pack/plugins/monitoring/public/views/no_data/index.html deleted file mode 100644 index c6fc97b639f42..0000000000000 --- a/x-pack/plugins/monitoring/public/views/no_data/index.html +++ /dev/null @@ -1,5 +0,0 @@ - -
-
-
-
diff --git a/x-pack/plugins/monitoring/public/views/no_data/index.js b/x-pack/plugins/monitoring/public/views/no_data/index.js deleted file mode 100644 index 4bbc490ce29ed..0000000000000 --- a/x-pack/plugins/monitoring/public/views/no_data/index.js +++ /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. - */ - -import { uiRoutes } from '../../angular/helpers/routes'; -import template from './index.html'; -import { NoDataController } from './controller'; - -uiRoutes.when('/no-data', { - template, - controller: NoDataController, -}); diff --git a/x-pack/plugins/monitoring/public/views/no_data/model_updater.js b/x-pack/plugins/monitoring/public/views/no_data/model_updater.js deleted file mode 100644 index 115dc782162a7..0000000000000 --- a/x-pack/plugins/monitoring/public/views/no_data/model_updater.js +++ /dev/null @@ -1,38 +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. - */ - -/* - * Class for handling model updates of an Angular controller - * Some properties are simple primitives like strings or booleans, - * but sometimes we need a property in the model to be an Array. For example, - * there may be multiple errors that happen in a flow. - * - * I use 1 method to handling property values that are either primitives or - * arrays, because it allows the callers to be a little more dumb. All they - * have to know is the property name, rather than the type as well. - */ -export class ModelUpdater { - constructor($scope, model) { - this.$scope = $scope; - this.model = model; - this.updateModel = this.updateModel.bind(this); - } - - updateModel(properties) { - const { $scope, model } = this; - const keys = Object.keys(properties); - $scope.$evalAsync(() => { - keys.forEach((key) => { - if (Array.isArray(model[key])) { - model[key].push(properties[key]); - } else { - model[key] = properties[key]; - } - }); - }); - } -} diff --git a/x-pack/plugins/monitoring/public/views/no_data/model_updater.test.js b/x-pack/plugins/monitoring/public/views/no_data/model_updater.test.js deleted file mode 100644 index b286bfb10a9e4..0000000000000 --- a/x-pack/plugins/monitoring/public/views/no_data/model_updater.test.js +++ /dev/null @@ -1,55 +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 { ModelUpdater } from './model_updater'; - -describe('Model Updater for Angular Controller with React Components', () => { - let $scope; - let model; - let updater; - - beforeEach(() => { - $scope = {}; - $scope.$evalAsync = (cb) => cb(); - - model = {}; - - updater = new ModelUpdater($scope, model); - jest.spyOn(updater, 'updateModel'); - }); - - test('should successfully construct an object', () => { - expect(typeof updater).toBe('object'); - expect(updater.updateModel).not.toHaveBeenCalled(); - }); - - test('updateModel method should add properties to the model', () => { - expect(typeof updater).toBe('object'); - updater.updateModel({ - foo: 'bar', - bar: 'baz', - error: 'monkeywrench', - }); - expect(model).toEqual({ - foo: 'bar', - bar: 'baz', - error: 'monkeywrench', - }); - }); - - test('updateModel method should push properties to the model if property is originally an array', () => { - model.errors = ['first']; - updater.updateModel({ - errors: 'second', - primitive: 'hello', - }); - expect(model).toEqual({ - errors: ['first', 'second'], - primitive: 'hello', - }); - }); -}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 52521b139d0fe..6b67d5d502fc4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18088,8 +18088,7 @@ "xpack.monitoring.cluster.overview.logstashPanel.withPersistentQueuesLabel": "永続キューあり", "xpack.monitoring.cluster.overview.pageTitle": "クラスターの概要", "xpack.monitoring.cluster.overviewTitle": "概要", - "xpack.monitoring.clusterAlertsNavigation.clusterAlertsLinkText": "クラスターアラート", - "xpack.monitoring.clustersNavigation.clustersLinkText": "クラスター", + "xpack.monitoring.cluster.listing.tabTitle": "クラスター", "xpack.monitoring.clusterStats.uuidNotFoundErrorMessage": "選択された時間範囲にクラスターが見つかりませんでした。UUID:{clusterUuid}", "xpack.monitoring.clusterStats.uuidNotSpecifiedErrorMessage": "{clusterUuid} が指定されていません", "xpack.monitoring.elasticsearch.ccr.ccrListingTable.alertsColumnTitle": "アラート", @@ -18101,10 +18100,10 @@ "xpack.monitoring.elasticsearch.ccr.ccrListingTable.syncLagOpsColumnTitle": "同期の遅延(オペレーション数)", "xpack.monitoring.elasticsearch.ccr.heading": "CCR", "xpack.monitoring.elasticsearch.ccr.pageTitle": "Elasticsearch - CCR", - "xpack.monitoring.elasticsearch.ccr.routeTitle": "Elasticsearch - CCR", + "xpack.monitoring.elasticsearch.ccr.title": "Elasticsearch - CCR", "xpack.monitoring.elasticsearch.ccr.shard.instanceTitle": "インデックス{followerIndex} シャード:{shardId}", "xpack.monitoring.elasticsearch.ccr.shard.pageTitle": "Elasticsearch Ccrシャード - インデックス:{followerIndex} シャード:{shardId}", - "xpack.monitoring.elasticsearch.ccr.shard.routeTitle": "Elasticsearch - CCR - シャード", + "xpack.monitoring.elasticsearch.ccr.shard.title": "Elasticsearch - CCR - シャード", "xpack.monitoring.elasticsearch.ccr.shardsTable.alertsColumnTitle": "アラート", "xpack.monitoring.elasticsearch.ccr.shardsTable.errorColumnTitle": "エラー", "xpack.monitoring.elasticsearch.ccr.shardsTable.lastFetchTimeColumnTitle": "最終取得時刻", @@ -18138,7 +18137,7 @@ "xpack.monitoring.elasticsearch.indexDetailStatus.totalShardsTitle": "合計シャード数", "xpack.monitoring.elasticsearch.indexDetailStatus.totalTitle": "合計", "xpack.monitoring.elasticsearch.indexDetailStatus.unassignedShardsTitle": "未割り当てシャード", - "xpack.monitoring.elasticsearch.indices.advanced.routeTitle": "Elasticsearch - インデックス - {indexName} - 高度な設定", + "xpack.monitoring.elasticsearch.index.advanced.title": "Elasticsearch - インデックス - {indexName} - 高度な設定", "xpack.monitoring.elasticsearch.indices.alertsColumnTitle": "アラート", "xpack.monitoring.elasticsearch.indices.dataTitle": "データ", "xpack.monitoring.elasticsearch.indices.documentCountTitle": "ドキュメントカウント", @@ -18147,8 +18146,8 @@ "xpack.monitoring.elasticsearch.indices.monitoringTablePlaceholder": "インデックスのフィルタリング…", "xpack.monitoring.elasticsearch.indices.nameTitle": "名前", "xpack.monitoring.elasticsearch.indices.noIndicesMatchYourSelectionDescription": "選択項目に一致するインデックスがありません。時間範囲を変更してみてください。", - "xpack.monitoring.elasticsearch.indices.overview.pageTitle": "インデックス:{indexName}", - "xpack.monitoring.elasticsearch.indices.overview.routeTitle": "Elasticsearch - インデックス - {indexName} - 概要", + "xpack.monitoring.elasticsearch.index.overview.pageTitle": "インデックス:{indexName}", + "xpack.monitoring.elasticsearch.index.overview.title": "Elasticsearch - インデックス - {indexName} - 概要", "xpack.monitoring.elasticsearch.indices.pageTitle": "デフォルトのインデックス", "xpack.monitoring.elasticsearch.indices.routeTitle": "Elasticsearch - インデックス", "xpack.monitoring.elasticsearch.indices.searchRateTitle": "検索レート", @@ -18167,7 +18166,7 @@ "xpack.monitoring.elasticsearch.mlJobListing.statusIconLabel": "ジョブ状態:{status}", "xpack.monitoring.elasticsearch.mlJobs.pageTitle": "Elasticsearch - 機械学習ジョブ", "xpack.monitoring.elasticsearch.mlJobs.routeTitle": "Elasticsearch - 機械学習ジョブ", - "xpack.monitoring.elasticsearch.node.advanced.routeTitle": "Elasticsearch - ノード - {nodeSummaryName} - 高度な設定", + "xpack.monitoring.elasticsearch.node.advanced.title": "Elasticsearch - ノード - {nodeName} - 高度な設定", "xpack.monitoring.elasticsearch.node.cells.tooltip.iconLabel": "このメトリックの詳細", "xpack.monitoring.elasticsearch.node.cells.tooltip.max": "最高値", "xpack.monitoring.elasticsearch.node.cells.tooltip.min": "最低値", @@ -18176,7 +18175,7 @@ "xpack.monitoring.elasticsearch.node.cells.trendingDownText": "ダウン", "xpack.monitoring.elasticsearch.node.cells.trendingUpText": "アップ", "xpack.monitoring.elasticsearch.node.overview.pageTitle": "Elasticsearchノード:{node}", - "xpack.monitoring.elasticsearch.node.overview.routeTitle": "Elasticsearch - ノード - {nodeName} - 概要", + "xpack.monitoring.elasticsearch.node.overview.title": "Elasticsearch - ノード - {nodeName} - 概要", "xpack.monitoring.elasticsearch.node.statusIconLabel": "ステータス:{status}", "xpack.monitoring.elasticsearch.nodeDetailStatus.alerts": "アラート", "xpack.monitoring.elasticsearch.nodeDetailStatus.dataLabel": "データ", @@ -18265,8 +18264,7 @@ "xpack.monitoring.es.nodeType.nodeLabel": "ノード", "xpack.monitoring.esNavigation.ccrLinkText": "CCR", "xpack.monitoring.esNavigation.indicesLinkText": "インデックス", - "xpack.monitoring.esNavigation.instance.advancedLinkText": "高度な設定", - "xpack.monitoring.esNavigation.instance.overviewLinkText": "概要", + "xpack.monitoring.esItemNavigation.advancedLinkText": "高度な設定", "xpack.monitoring.esNavigation.jobsLinkText": "機械学習ジョブ", "xpack.monitoring.esNavigation.nodesLinkText": "ノード", "xpack.monitoring.esNavigation.overviewLinkText": "概要", @@ -18385,7 +18383,6 @@ "xpack.monitoring.logstash.node.advanced.pageTitle": "Logstashノード:{nodeName}", "xpack.monitoring.logstash.node.advanced.routeTitle": "Logstash - {nodeName} - 高度な設定", "xpack.monitoring.logstash.node.pageTitle": "Logstashノード:{nodeName}", - "xpack.monitoring.logstash.node.pipelines.notAvailableDescription": "パイプラインの監視は Logstash バージョン 6.0.0 以降でのみ利用できます。このノードはバージョン {logstashVersion} を実行しています。", "xpack.monitoring.logstash.node.pipelines.pageTitle": "Logstashノードパイプライン:{nodeName}", "xpack.monitoring.logstash.node.pipelines.routeTitle": "Logstash - {nodeName} - パイプライン", "xpack.monitoring.logstash.node.routeTitle": "Logstash - {nodeName}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a139b4f2fa240..6d2c204cfc2d6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18364,8 +18364,7 @@ "xpack.monitoring.cluster.overview.logstashPanel.withPersistentQueuesLabel": "持久性队列", "xpack.monitoring.cluster.overview.pageTitle": "集群概览", "xpack.monitoring.cluster.overviewTitle": "概览", - "xpack.monitoring.clusterAlertsNavigation.clusterAlertsLinkText": "集群告警", - "xpack.monitoring.clustersNavigation.clustersLinkText": "集群", + "xpack.monitoring.cluster.listing.tabTitle": "集群", "xpack.monitoring.clusterStats.uuidNotFoundErrorMessage": "在选定时间范围内找不到该集群。UUID:{clusterUuid}", "xpack.monitoring.clusterStats.uuidNotSpecifiedErrorMessage": "{clusterUuid} 未指定", "xpack.monitoring.elasticsearch.ccr.ccrListingTable.alertsColumnTitle": "告警", @@ -18377,10 +18376,10 @@ "xpack.monitoring.elasticsearch.ccr.ccrListingTable.syncLagOpsColumnTitle": "同步延迟(操作)", "xpack.monitoring.elasticsearch.ccr.heading": "CCR", "xpack.monitoring.elasticsearch.ccr.pageTitle": "Elasticsearch Ccr", - "xpack.monitoring.elasticsearch.ccr.routeTitle": "Elasticsearch - CCR", + "xpack.monitoring.elasticsearch.ccr.title": "Elasticsearch - CCR", "xpack.monitoring.elasticsearch.ccr.shard.instanceTitle": "索引:{followerIndex} 分片:{shardId}", "xpack.monitoring.elasticsearch.ccr.shard.pageTitle": "Elasticsearch Ccr 分片 - 索引:{followerIndex} 分片:{shardId}", - "xpack.monitoring.elasticsearch.ccr.shard.routeTitle": "Elasticsearch - CCR - 分片", + "xpack.monitoring.elasticsearch.ccr.shard.title": "Elasticsearch - CCR - 分片", "xpack.monitoring.elasticsearch.ccr.shardsTable.alertsColumnTitle": "告警", "xpack.monitoring.elasticsearch.ccr.shardsTable.errorColumnTitle": "错误", "xpack.monitoring.elasticsearch.ccr.shardsTable.lastFetchTimeColumnTitle": "上次提取时间", @@ -18414,7 +18413,7 @@ "xpack.monitoring.elasticsearch.indexDetailStatus.totalShardsTitle": "分片合计", "xpack.monitoring.elasticsearch.indexDetailStatus.totalTitle": "合计", "xpack.monitoring.elasticsearch.indexDetailStatus.unassignedShardsTitle": "未分配分片", - "xpack.monitoring.elasticsearch.indices.advanced.routeTitle": "Elasticsearch - 索引 - {indexName} - 高级", + "xpack.monitoring.elasticsearch.index.advanced.title": "Elasticsearch - 索引 - {indexName} - 高级", "xpack.monitoring.elasticsearch.indices.alertsColumnTitle": "告警", "xpack.monitoring.elasticsearch.indices.dataTitle": "数据", "xpack.monitoring.elasticsearch.indices.documentCountTitle": "文档计数", @@ -18423,8 +18422,8 @@ "xpack.monitoring.elasticsearch.indices.monitoringTablePlaceholder": "筛选索引……", "xpack.monitoring.elasticsearch.indices.nameTitle": "名称", "xpack.monitoring.elasticsearch.indices.noIndicesMatchYourSelectionDescription": "没有索引匹配您的选择。请尝试更改时间范围选择。", - "xpack.monitoring.elasticsearch.indices.overview.pageTitle": "索引:{indexName}", - "xpack.monitoring.elasticsearch.indices.overview.routeTitle": "Elasticsearch - 索引 - {indexName} - 概览", + "xpack.monitoring.elasticsearch.index.overview.pageTitle": "索引:{indexName}", + "xpack.monitoring.elasticsearch.index.overview.title": "Elasticsearch - 索引 - {indexName} - 概览", "xpack.monitoring.elasticsearch.indices.pageTitle": "Elasticsearch 索引", "xpack.monitoring.elasticsearch.indices.routeTitle": "Elasticsearch - 索引", "xpack.monitoring.elasticsearch.indices.searchRateTitle": "搜索速率", @@ -18443,7 +18442,7 @@ "xpack.monitoring.elasticsearch.mlJobListing.statusIconLabel": "作业状态:{status}", "xpack.monitoring.elasticsearch.mlJobs.pageTitle": "Elasticsearch Machine Learning 作业", "xpack.monitoring.elasticsearch.mlJobs.routeTitle": "Elasticsearch - Machine Learning 作业", - "xpack.monitoring.elasticsearch.node.advanced.routeTitle": "Elasticsearch - 节点 - {nodeSummaryName} - 高级", + "xpack.monitoring.elasticsearch.node.advanced.title": "Elasticsearch - 节点 - {nodeName} - 高级", "xpack.monitoring.elasticsearch.node.cells.tooltip.iconLabel": "有关此指标的更多信息", "xpack.monitoring.elasticsearch.node.cells.tooltip.max": "最大值", "xpack.monitoring.elasticsearch.node.cells.tooltip.min": "最小值", @@ -18452,7 +18451,7 @@ "xpack.monitoring.elasticsearch.node.cells.trendingDownText": "向下", "xpack.monitoring.elasticsearch.node.cells.trendingUpText": "向上", "xpack.monitoring.elasticsearch.node.overview.pageTitle": "Elasticsearch 节点:{node}", - "xpack.monitoring.elasticsearch.node.overview.routeTitle": "Elasticsearch - 节点 - {nodeName} - 概览", + "xpack.monitoring.elasticsearch.node.overview.title": "Elasticsearch - 节点 - {nodeName} - 概览", "xpack.monitoring.elasticsearch.node.statusIconLabel": "状态:{status}", "xpack.monitoring.elasticsearch.nodeDetailStatus.alerts": "告警", "xpack.monitoring.elasticsearch.nodeDetailStatus.dataLabel": "数据", @@ -18541,8 +18540,7 @@ "xpack.monitoring.es.nodeType.nodeLabel": "节点", "xpack.monitoring.esNavigation.ccrLinkText": "CCR", "xpack.monitoring.esNavigation.indicesLinkText": "索引", - "xpack.monitoring.esNavigation.instance.advancedLinkText": "高级", - "xpack.monitoring.esNavigation.instance.overviewLinkText": "概览", + "xpack.monitoring.esItemNavigation.advancedLinkText": "高级", "xpack.monitoring.esNavigation.jobsLinkText": "Machine Learning 作业", "xpack.monitoring.esNavigation.nodesLinkText": "节点", "xpack.monitoring.esNavigation.overviewLinkText": "概览", @@ -18661,7 +18659,6 @@ "xpack.monitoring.logstash.node.advanced.pageTitle": "Logstash 节点:{nodeName}", "xpack.monitoring.logstash.node.advanced.routeTitle": "Logstash - {nodeName} - 高级", "xpack.monitoring.logstash.node.pageTitle": "Logstash 节点:{nodeName}", - "xpack.monitoring.logstash.node.pipelines.notAvailableDescription": "仅 Logstash 版本 6.0.0 或更高版本提供管道监测功能。此节点正在运行版本 {logstashVersion}。", "xpack.monitoring.logstash.node.pipelines.pageTitle": "Logstash 节点管道:{nodeName}", "xpack.monitoring.logstash.node.pipelines.routeTitle": "Logstash - {nodeName} - 管道", "xpack.monitoring.logstash.node.routeTitle": "Logstash - {nodeName}", From c272b2d95fab6831893e653ccd943c36a94379dc Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 15:13:45 -0400 Subject: [PATCH 19/54] Replace Inspector's EuiPopover with EuiComboBox (#113566) (#115412) * Replacing EuiPopover with EuiComboBox * The combobox will help alleviate issues when the list of options is very long * Refactoring the Combobox to listen for change events * Added an onChange handler * Renamed the method to render the combobox * Commented out additional blocks of code before final refactor * Finished refactoring the Request Selector to use EUI Combobox * Removed three helper methods for the EUIPopover. * `togglePopover()` * `closePopover()` * `renderRequestDropdownItem()` * Removed the local state object and interface (no longer needed) * Renamed the const `options` to `selectedOptions` in `handleSelectd()` method to better reflect where the options array was coming from. * Updating tests and translations * Fixed the inspector functional test to use comboBox service * Removed two unused translations * Updating Combobox options to pass data-test-sub string * Updated two tests for Combobox single option * Updated the test expectations to the default string * Both tests were looking for a named string instead of a default message * Adding error handling to Inspector combobox * Checking for the item status code * Adding a " (failed)" message if the status code returns `2` * Updating test to look for "Chart_data" instead of "Chartdata" * Updating two tests to validate single combobox options * Added helper method to check default text against combobox options * Added helper method to get the selected combobox option * Checking two inspector instances using helpers * Adding a defensive check to helper method. * Correct a type error in test return * Adding back translated failLabel Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Nathan L Smith Co-authored-by: Trevor Pierce <1Copenut@users.noreply.github.com> Co-authored-by: Nathan L Smith --- .../requests/components/request_selector.tsx | 142 +++++------------- test/functional/apps/discover/_inspector.ts | 4 +- test/functional/apps/visualize/_vega_chart.ts | 6 +- test/functional/services/inspector.ts | 43 ++++-- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../apps/maps/embeddable/dashboard.js | 7 +- 7 files changed, 85 insertions(+), 119 deletions(-) diff --git a/src/plugins/inspector/public/views/requests/components/request_selector.tsx b/src/plugins/inspector/public/views/requests/components/request_selector.tsx index 2d94c7ff5bb18..04fac0bd93b7e 100644 --- a/src/plugins/inspector/public/views/requests/components/request_selector.tsx +++ b/src/plugins/inspector/public/views/requests/components/request_selector.tsx @@ -13,118 +13,73 @@ import { i18n } from '@kbn/i18n'; import { EuiBadge, - EuiButtonEmpty, - EuiContextMenuPanel, - EuiContextMenuItem, + EuiComboBox, + EuiComboBoxOptionOption, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, - EuiPopover, - EuiTextColor, EuiToolTip, } from '@elastic/eui'; import { RequestStatus } from '../../../../common/adapters'; import { Request } from '../../../../common/adapters/request/types'; -interface RequestSelectorState { - isPopoverOpen: boolean; -} - interface RequestSelectorProps { requests: Request[]; selectedRequest: Request; - onRequestChanged: Function; + onRequestChanged: (request: Request) => void; } -export class RequestSelector extends Component { +export class RequestSelector extends Component { static propTypes = { requests: PropTypes.array.isRequired, selectedRequest: PropTypes.object.isRequired, onRequestChanged: PropTypes.func, }; - state = { - isPopoverOpen: false, - }; + handleSelected = (selectedOptions: Array>) => { + const selectedOption = this.props.requests.find( + (request) => request.id === selectedOptions[0].value + ); - togglePopover = () => { - this.setState((prevState: RequestSelectorState) => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); + if (selectedOption) { + this.props.onRequestChanged(selectedOption); + } }; - closePopover = () => { - this.setState({ - isPopoverOpen: false, + renderRequestCombobox() { + const options = this.props.requests.map((item) => { + const hasFailed = item.status === RequestStatus.ERROR; + const testLabel = item.name.replace(/\s+/, '_'); + + return { + 'data-test-subj': `inspectorRequestChooser${testLabel}`, + label: hasFailed + ? `${item.name} ${i18n.translate('inspector.requests.failedLabel', { + defaultMessage: ' (failed)', + })}` + : item.name, + value: item.id, + }; }); - }; - - renderRequestDropdownItem = (request: Request, index: number) => { - const hasFailed = request.status === RequestStatus.ERROR; - const inProgress = request.status === RequestStatus.PENDING; return ( - { - this.props.onRequestChanged(request); - this.closePopover(); - }} - toolTipContent={request.description} - toolTipPosition="left" - data-test-subj={`inspectorRequestChooser${request.name}`} - > - - {request.name} - - {hasFailed && ( - - )} - - {inProgress && ( - - )} - - - ); - }; - - renderRequestDropdown() { - const button = ( - - {this.props.selectedRequest.name} - - ); - - return ( - - - + isClearable={false} + onChange={this.handleSelected} + options={options} + prepend="Request" + selectedOptions={[ + { + label: this.props.selectedRequest.name, + value: this.props.selectedRequest.id, + }, + ]} + singleSelection={{ asPlainText: true }} + /> ); } @@ -132,23 +87,8 @@ export class RequestSelector extends Component - - - - - - - {requests.length <= 1 && ( -
- {selectedRequest.name} -
- )} - {requests.length > 1 && this.renderRequestDropdown()} -
+ + {requests.length && this.renderRequestCombobox()} {selectedRequest.status !== RequestStatus.PENDING && ( { - const requests = await inspector.getRequestNames(); + const singleExampleRequest = await inspector.hasSingleRequest(); + const selectedExampleRequest = await inspector.getSelectedOption(); - expect(requests).to.be('Unnamed request #0'); + expect(singleExampleRequest).to.be(true); + expect(selectedExampleRequest).to.equal('Unnamed request #0'); }); it('should log the request statistic', async () => { diff --git a/test/functional/services/inspector.ts b/test/functional/services/inspector.ts index 5364dbebe904c..753d9b7b0b85e 100644 --- a/test/functional/services/inspector.ts +++ b/test/functional/services/inspector.ts @@ -16,6 +16,7 @@ export class InspectorService extends FtrService { private readonly flyout = this.ctx.getService('flyout'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly find = this.ctx.getService('find'); + private readonly comboBox = this.ctx.getService('comboBox'); private async getIsEnabled(): Promise { const ariaDisabled = await this.testSubjects.getAttribute('openInspectorButton', 'disabled'); @@ -206,20 +207,29 @@ export class InspectorService extends FtrService { } /** - * Returns request name as the comma-separated string + * Returns the selected option value from combobox */ - public async getRequestNames(): Promise { + public async getSelectedOption(): Promise { await this.openInspectorRequestsView(); - const requestChooserExists = await this.testSubjects.exists('inspectorRequestChooser'); - if (requestChooserExists) { - await this.testSubjects.click('inspectorRequestChooser'); - const menu = await this.testSubjects.find('inspectorRequestChooserMenuPanel'); - const requestNames = await menu.getVisibleText(); - return requestNames.trim().split('\n').join(','); + const selectedOption = await this.comboBox.getComboBoxSelectedOptions( + 'inspectorRequestChooser' + ); + + if (selectedOption.length !== 1) { + return 'Combobox has multiple options'; } - const singleRequest = await this.testSubjects.find('inspectorRequestName'); - return await singleRequest.getVisibleText(); + return selectedOption[0]; + } + + /** + * Returns request name as the comma-separated string from combobox + */ + public async getRequestNames(): Promise { + await this.openInspectorRequestsView(); + + const comboBoxOptions = await this.comboBox.getOptionsList('inspectorRequestChooser'); + return comboBoxOptions.trim().split('\n').join(','); } public getOpenRequestStatisticButton() { @@ -233,4 +243,17 @@ export class InspectorService extends FtrService { public getOpenRequestDetailResponseButton() { return this.testSubjects.find('inspectorRequestDetailResponse'); } + + /** + * Returns true if the value equals the combobox options list + * @param value default combobox single option text + */ + public async hasSingleRequest( + value: string = "You've selected all available options" + ): Promise { + await this.openInspectorRequestsView(); + const comboBoxOptions = await this.comboBox.getOptionsList('inspectorRequestChooser'); + + return value === comboBoxOptions; + } } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6b67d5d502fc4..96c57ef71eeb8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4168,7 +4168,6 @@ "inspector.requests.noRequestsLoggedTitle": "リクエストが記録されていません", "inspector.requests.requestFailedTooltipTitle": "リクエストに失敗しました", "inspector.requests.requestInProgressAriaLabel": "リクエストが進行中", - "inspector.requests.requestLabel": "リクエスト:", "inspector.requests.requestsDescriptionTooltip": "データを収集したリクエストを表示します", "inspector.requests.requestsTitle": "リクエスト", "inspector.requests.requestSucceededTooltipTitle": "リクエスト成功", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 6d2c204cfc2d6..81e03b2024fed 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4207,7 +4207,6 @@ "inspector.requests.noRequestsLoggedTitle": "未记录任何请求", "inspector.requests.requestFailedTooltipTitle": "请求失败", "inspector.requests.requestInProgressAriaLabel": "进行中的请求", - "inspector.requests.requestLabel": "请求:", "inspector.requests.requestsDescriptionTooltip": "查看已收集数据的请求", "inspector.requests.requestsTitle": "请求", "inspector.requests.requestSucceededTooltipTitle": "请求成功", diff --git a/x-pack/test/functional/apps/maps/embeddable/dashboard.js b/x-pack/test/functional/apps/maps/embeddable/dashboard.js index 6c962c98c6a98..a892a0d547339 100644 --- a/x-pack/test/functional/apps/maps/embeddable/dashboard.js +++ b/x-pack/test/functional/apps/maps/embeddable/dashboard.js @@ -77,9 +77,12 @@ export default function ({ getPageObjects, getService }) { await inspector.close(); await dashboardPanelActions.openInspectorByTitle('geo grid vector grid example'); - const gridExampleRequestNames = await inspector.getRequestNames(); + const singleExampleRequest = await inspector.hasSingleRequest(); + const selectedExampleRequest = await inspector.getSelectedOption(); await inspector.close(); - expect(gridExampleRequestNames).to.equal('logstash-*'); + + expect(singleExampleRequest).to.be(true); + expect(selectedExampleRequest).to.equal('logstash-*'); }); it('should apply container state (time, query, filters) to embeddable when loaded', async () => { From d73038dff68eed46d75362d776eb5b676a2b898a Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 18 Oct 2021 12:50:53 -0700 Subject: [PATCH 20/54] [Reporting] Revisit handling timeouts for different phases of screenshot capture (#113807) (#115418) * [Reporting] Revisit handling timeouts for different phases of screenshot capture * remove translations for changed text * add wip unit test * simplify class * todo more testing * fix ts * update snapshots * simplify open_url * fixup me * move setupPage to a method of the ObservableHandler class * do not pass entire config object to helper functions * distinguish internal timeouts vs external timeout * add tests for waitUntil * checkIsPageOpen test * restore passing of renderErrors * updates per feedback * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts Co-authored-by: Michael Dokolin * fix parsing * apply simplifications consistently * dont main waitUntil a higher order component * resolve the timeouts options outside of the service * comment correction Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Dokolin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Dokolin --- .../lib/screenshots/check_browser_open.ts | 22 -- .../screenshots/get_number_of_items.test.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 12 +- .../reporting/server/lib/screenshots/index.ts | 19 ++ .../server/lib/screenshots/observable.test.ts | 10 +- .../server/lib/screenshots/observable.ts | 179 ++++----------- .../screenshots/observable_handler.test.ts | 160 +++++++++++++ .../lib/screenshots/observable_handler.ts | 214 ++++++++++++++++++ .../server/lib/screenshots/open_url.ts | 19 +- .../server/lib/screenshots/wait_for_render.ts | 8 +- .../screenshots/wait_for_visualizations.ts | 13 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 13 files changed, 465 insertions(+), 206 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts create mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts create mode 100644 x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts diff --git a/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts b/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts deleted file mode 100644 index 95bfa7af870fe..0000000000000 --- a/x-pack/plugins/reporting/server/lib/screenshots/check_browser_open.ts +++ /dev/null @@ -1,22 +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 { HeadlessChromiumDriver } from '../../browsers'; -import { getChromiumDisconnectedError } from '../../browsers/chromium'; - -/* - * Call this function within error-handling `catch` blocks while setup and wait - * for the Kibana URL to be ready for screenshot. This detects if a block of - * code threw an exception because the page is closed or crashed. - * - * Also call once after `setup$` fires in the screenshot pipeline - */ -export const checkPageIsOpen = (browser: HeadlessChromiumDriver) => { - if (!browser.isPageOpen()) { - throw getChromiumDisconnectedError(); - } -}; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts index 0ca622d67283c..f160fcb8b27ad 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.test.ts @@ -6,6 +6,7 @@ */ import { set } from 'lodash'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; import { createMockBrowserDriverFactory, @@ -25,6 +26,7 @@ describe('getNumberOfItems', () => { let layout: LayoutInstance; let logger: jest.Mocked; let browser: HeadlessChromiumDriver; + let timeout: number; beforeEach(async () => { const schema = createMockConfigSchema(set({}, 'capture.timeouts.waitForElements', 0)); @@ -34,6 +36,7 @@ describe('getNumberOfItems', () => { captureConfig = config.get('capture'); layout = createMockLayoutInstance(captureConfig); logger = createMockLevelLogger(); + timeout = durationToNumber(captureConfig.timeouts.waitForElements); await createMockBrowserDriverFactory(core, logger, { evaluate: jest.fn( @@ -62,7 +65,7 @@ describe('getNumberOfItems', () => {
`; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(10); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(10); }); it('should determine the number of items by selector ', async () => { @@ -72,7 +75,7 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(3); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(3); }); it('should fall back to the selector when the attribute is empty', async () => { @@ -82,6 +85,6 @@ describe('getNumberOfItems', () => { `; - await expect(getNumberOfItems(captureConfig, browser, layout, logger)).resolves.toBe(2); + await expect(getNumberOfItems(timeout, browser, layout, logger)).resolves.toBe(2); }); }); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts index 9bbd8e07898be..9e5dfa180fd0f 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/get_number_of_items.ts @@ -6,15 +6,13 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, layout: LayoutInstance, logger: LevelLogger @@ -33,7 +31,6 @@ export const getNumberOfItems = async ( // the dashboard is using the `itemsCountAttribute` attribute to let us // know how many items to expect since gridster incrementally adds panels // we have to use this hint to wait for all of them - const timeout = durationToNumber(captureConfig.timeouts.waitForElements); await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, { timeout }, @@ -65,11 +62,8 @@ export const getNumberOfItems = async ( logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.readVisualizationsError', { - defaultMessage: `An error occurred when trying to read the page for visualization panel info. You may need to increase '{configKey}'. {error}`, - values: { - error: err, - configKey: 'xpack.reporting.capture.timeouts.waitForElements', - }, + defaultMessage: `An error occurred when trying to read the page for visualization panel info: {error}`, + values: { error: err }, }) ); } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index 1ca8b5e00fee4..2b8a0d6207a9b 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -12,6 +12,19 @@ import { LayoutInstance } from '../layouts'; export { getScreenshots$ } from './observable'; +export interface PhaseInstance { + timeoutValue: number; + configValue: string; + label: string; +} + +export interface PhaseTimeouts { + openUrl: PhaseInstance; + waitForElements: PhaseInstance; + renderComplete: PhaseInstance; + loadDelay: number; +} + export interface ScreenshotObservableOpts { logger: LevelLogger; urlsOrUrlLocatorTuples: UrlOrUrlLocatorTuple[]; @@ -49,6 +62,12 @@ export interface Screenshot { description: string | null; } +export interface PageSetupResults { + elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; + timeRange: string | null; + error?: Error; +} + export interface ScreenshotResults { timeRange: string | null; screenshots: Screenshot[]; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index c33bdad44f9e7..3dc06996f0f04 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -98,7 +98,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -173,7 +172,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -225,7 +223,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { @@ -314,8 +311,7 @@ describe('Screenshot Observable Pipeline', () => { }, }, ], - "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], - "renderErrors": undefined, + "error": [Error: The "wait for elements" phase encountered an error: Error: An error occurred when trying to read the page for visualization panel info: Error: Mock error!], "screenshots": Array [ Object { "data": Object { @@ -357,8 +353,7 @@ describe('Screenshot Observable Pipeline', () => { }, }, ], - "error": [Error: An error occurred when trying to read the page for visualization panel info. You may need to increase 'xpack.reporting.capture.timeouts.waitForElements'. Error: Mock error!], - "renderErrors": undefined, + "error": [Error: An error occurred when trying to read the page for visualization panel info: Error: Mock error!], "screenshots": Array [ Object { "data": Object { @@ -465,7 +460,6 @@ describe('Screenshot Observable Pipeline', () => { }, ], "error": undefined, - "renderErrors": undefined, "screenshots": Array [ Object { "data": Object { diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts index b8fecdc91a3f2..173dbaaf99557 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts @@ -8,138 +8,70 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; +import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriverFactory } from '../../browsers'; import { CaptureConfig } from '../../types'; -import { ElementsPositionAndAttribute, ScreenshotObservableOpts, ScreenshotResults } from './'; -import { checkPageIsOpen } from './check_browser_open'; -import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; -import { getElementPositionAndAttributes } from './get_element_position_data'; -import { getNumberOfItems } from './get_number_of_items'; -import { getScreenshots } from './get_screenshots'; -import { getTimeRange } from './get_time_range'; -import { getRenderErrors } from './get_render_errors'; -import { injectCustomCss } from './inject_css'; -import { openUrl } from './open_url'; -import { waitForRenderComplete } from './wait_for_render'; -import { waitForVisualizations } from './wait_for_visualizations'; +import { + ElementPosition, + ElementsPositionAndAttribute, + PageSetupResults, + ScreenshotObservableOpts, + ScreenshotResults, +} from './'; +import { ScreenshotObservableHandler } from './observable_handler'; -const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200; -const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800; +export { ElementPosition, ElementsPositionAndAttribute, ScreenshotResults }; -interface ScreenSetupData { - elementsPositionAndAttributes: ElementsPositionAndAttribute[] | null; - timeRange: string | null; - renderErrors?: string[]; - error?: Error; -} +const getTimeouts = (captureConfig: CaptureConfig) => ({ + openUrl: { + timeoutValue: durationToNumber(captureConfig.timeouts.openUrl), + configValue: `xpack.reporting.capture.timeouts.openUrl`, + label: 'open URL', + }, + waitForElements: { + timeoutValue: durationToNumber(captureConfig.timeouts.waitForElements), + configValue: `xpack.reporting.capture.timeouts.waitForElements`, + label: 'wait for elements', + }, + renderComplete: { + timeoutValue: durationToNumber(captureConfig.timeouts.renderComplete), + configValue: `xpack.reporting.capture.timeouts.renderComplete`, + label: 'render complete', + }, + loadDelay: durationToNumber(captureConfig.loadDelay), +}); export function getScreenshots$( captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory, - { - logger, - urlsOrUrlLocatorTuples, - conditionalHeaders, - layout, - browserTimezone, - }: ScreenshotObservableOpts + opts: ScreenshotObservableOpts ): Rx.Observable { const apmTrans = apm.startTransaction(`reporting screenshot pipeline`, 'reporting'); - const apmCreatePage = apmTrans?.startSpan('create_page', 'wait'); - const create$ = browserDriverFactory.createPage({ browserTimezone }, logger); + const { browserTimezone, logger } = opts; - return create$.pipe( + return browserDriverFactory.createPage({ browserTimezone }, logger).pipe( mergeMap(({ driver, exit$ }) => { apmCreatePage?.end(); exit$.subscribe({ error: () => apmTrans?.end() }); - return Rx.from(urlsOrUrlLocatorTuples).pipe( - concatMap((urlOrUrlLocatorTuple, index) => { - const setup$: Rx.Observable = Rx.of(1).pipe( - mergeMap(() => { - // If we're moving to another page in the app, we'll want to wait for the app to tell us - // it's loaded the next page. - const page = index + 1; - const pageLoadSelector = - page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + const screen = new ScreenshotObservableHandler(driver, opts, getTimeouts(captureConfig)); - return openUrl( - captureConfig, - driver, - urlOrUrlLocatorTuple, - pageLoadSelector, - conditionalHeaders, - logger - ); - }), - mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), - mergeMap(async (itemsCount) => { - // set the viewport to the dimentions from the job, to allow elements to flow into the expected layout - const viewport = layout.getViewport(itemsCount) || getDefaultViewPort(); - await Promise.all([ - driver.setViewport(viewport, logger), - waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), - ]); - }), - mergeMap(async () => { - // Waiting till _after_ elements have rendered before injecting our CSS - // allows for them to be displayed properly in many cases - await injectCustomCss(driver, layout, logger); - - const apmPositionElements = apmTrans?.startSpan('position_elements', 'correction'); - if (layout.positionElements) { - // position panel elements for print layout - await layout.positionElements(driver, logger); - } - if (apmPositionElements) apmPositionElements.end(); - - await waitForRenderComplete(captureConfig, driver, layout, logger); - }), - mergeMap(async () => { - return await Promise.all([ - getTimeRange(driver, layout, logger), - getElementPositionAndAttributes(driver, layout, logger), - getRenderErrors(driver, layout, logger), - ]).then(([timeRange, elementsPositionAndAttributes, renderErrors]) => ({ - elementsPositionAndAttributes, - timeRange, - renderErrors, - })); - }), + return Rx.from(opts.urlsOrUrlLocatorTuples).pipe( + concatMap((urlOrUrlLocatorTuple, index) => { + return Rx.of(1).pipe( + screen.setupPage(index, urlOrUrlLocatorTuple, apmTrans), catchError((err) => { - checkPageIsOpen(driver); // if browser has closed, throw a relevant error about it + screen.checkPageIsOpen(); // this fails the job if the browser has closed logger.error(err); - return Rx.of({ - elementsPositionAndAttributes: null, - timeRange: null, - error: err, - }); - }) - ); - - return setup$.pipe( + return Rx.of({ ...defaultSetupResult, error: err }); // allow failover screenshot capture + }), takeUntil(exit$), - mergeMap(async (data: ScreenSetupData): Promise => { - checkPageIsOpen(driver); // re-check that the browser has not closed - - const elements = data.elementsPositionAndAttributes - ? data.elementsPositionAndAttributes - : getDefaultElementPosition(layout.getViewport(1)); - const screenshots = await getScreenshots(driver, elements, logger); - const { timeRange, error: setupError, renderErrors } = data; - return { - timeRange, - screenshots, - error: setupError, - renderErrors, - elementsPositionAndAttributes: elements, - }; - }) + screen.getScreenshots() ); }), - take(urlsOrUrlLocatorTuples.length), + take(opts.urlsOrUrlLocatorTuples.length), toArray() ); }), @@ -147,30 +79,7 @@ export function getScreenshots$( ); } -/* - * If Kibana is showing a non-HTML error message, the viewport might not be - * provided by the browser. - */ -const getDefaultViewPort = () => ({ - height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, - width: DEFAULT_SCREENSHOT_CLIP_WIDTH, - zoom: 1, -}); -/* - * If an error happens setting up the page, we don't know if there actually - * are any visualizations showing. These defaults should help capture the page - * enough for the user to see the error themselves - */ -const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { - const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; - const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; - - const defaultObject = { - position: { - boundingClientRect: { top: 0, left: 0, height, width }, - scroll: { x: 0, y: 0 }, - }, - attributes: {}, - }; - return [defaultObject]; +const defaultSetupResult: PageSetupResults = { + elementsPositionAndAttributes: null, + timeRange: null, }; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts new file mode 100644 index 0000000000000..25a8bed370d86 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.test.ts @@ -0,0 +1,160 @@ +/* + * 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 Rx from 'rxjs'; +import { first, map } from 'rxjs/operators'; +import { HeadlessChromiumDriver } from '../../browsers'; +import { ReportingConfigType } from '../../config'; +import { ConditionalHeaders } from '../../export_types/common'; +import { + createMockBrowserDriverFactory, + createMockConfigSchema, + createMockLayoutInstance, + createMockLevelLogger, + createMockReportingCore, +} from '../../test_helpers'; +import { LayoutInstance } from '../layouts'; +import { PhaseTimeouts, ScreenshotObservableOpts } from './'; +import { ScreenshotObservableHandler } from './observable_handler'; + +const logger = createMockLevelLogger(); + +describe('ScreenshotObservableHandler', () => { + let captureConfig: ReportingConfigType['capture']; + let layout: LayoutInstance; + let conditionalHeaders: ConditionalHeaders; + let opts: ScreenshotObservableOpts; + let timeouts: PhaseTimeouts; + let driver: HeadlessChromiumDriver; + + beforeAll(async () => { + captureConfig = { + timeouts: { + openUrl: 30000, + waitForElements: 30000, + renderComplete: 30000, + }, + loadDelay: 5000, + } as unknown as typeof captureConfig; + + layout = createMockLayoutInstance(captureConfig); + + conditionalHeaders = { + headers: { testHeader: 'testHeadValue' }, + conditions: {} as unknown as ConditionalHeaders['conditions'], + }; + + opts = { + conditionalHeaders, + layout, + logger, + urlsOrUrlLocatorTuples: [], + }; + + timeouts = { + openUrl: { + timeoutValue: 60000, + configValue: `xpack.reporting.capture.timeouts.openUrl`, + label: 'open URL', + }, + waitForElements: { + timeoutValue: 30000, + configValue: `xpack.reporting.capture.timeouts.waitForElements`, + label: 'wait for elements', + }, + renderComplete: { + timeoutValue: 60000, + configValue: `xpack.reporting.capture.timeouts.renderComplete`, + label: 'render complete', + }, + loadDelay: 5000, + }; + }); + + beforeEach(async () => { + const reporting = await createMockReportingCore(createMockConfigSchema()); + const driverFactory = await createMockBrowserDriverFactory(reporting, logger); + ({ driver } = await driverFactory.createPage({}, logger).pipe(first()).toPromise()); + driver.isPageOpen = jest.fn().mockImplementation(() => true); + }); + + describe('waitUntil', () => { + it('catches TimeoutError and references the timeout config in a custom message', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.interval(1000).pipe( + screenshots.waitUntil({ + timeoutValue: 200, + configValue: 'test.config.value', + label: 'Test Config', + }) + ); + + const testPipeline = () => test$.toPromise(); + await expect(testPipeline).rejects.toMatchInlineSnapshot( + `[Error: The "Test Config" phase took longer than 0.2 seconds. You may need to increase "test.config.value": TimeoutError: Timeout has occurred]` + ); + }); + + it('catches other Errors and explains where they were thrown', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.throwError(new Error(`Test Error to Throw`)).pipe( + screenshots.waitUntil({ + timeoutValue: 200, + configValue: 'test.config.value', + label: 'Test Config', + }) + ); + + const testPipeline = () => test$.toPromise(); + await expect(testPipeline).rejects.toMatchInlineSnapshot( + `[Error: The "Test Config" phase encountered an error: Error: Test Error to Throw]` + ); + }); + + it('is a pass-through if there is no Error', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of('nice to see you').pipe( + screenshots.waitUntil({ + timeoutValue: 20, + configValue: 'xxxxxxxxxxxxxxxxx', + label: 'xxxxxxxxxxx', + }) + ); + + await expect(test$.toPromise()).resolves.toBe(`nice to see you`); + }); + }); + + describe('checkPageIsOpen', () => { + it('throws a decorated Error when page is not open', async () => { + driver.isPageOpen = jest.fn().mockImplementation(() => false); + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of(234455).pipe( + map((input) => { + screenshots.checkPageIsOpen(); + return input; + }) + ); + + await expect(test$.toPromise()).rejects.toMatchInlineSnapshot( + `[Error: Browser was closed unexpectedly! Check the server logs for more info.]` + ); + }); + + it('is a pass-through when the page is open', async () => { + const screenshots = new ScreenshotObservableHandler(driver, opts, timeouts); + const test$ = Rx.of(234455).pipe( + map((input) => { + screenshots.checkPageIsOpen(); + return input; + }) + ); + + await expect(test$.toPromise()).resolves.toBe(234455); + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts new file mode 100644 index 0000000000000..87c247273ef04 --- /dev/null +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable_handler.ts @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import apm from 'elastic-apm-node'; +import * as Rx from 'rxjs'; +import { catchError, mergeMap, timeout } from 'rxjs/operators'; +import { numberToDuration } from '../../../common/schema_utils'; +import { UrlOrUrlLocatorTuple } from '../../../common/types'; +import { HeadlessChromiumDriver } from '../../browsers'; +import { getChromiumDisconnectedError } from '../../browsers/chromium'; +import { + PageSetupResults, + PhaseInstance, + PhaseTimeouts, + ScreenshotObservableOpts, + ScreenshotResults, +} from './'; +import { getElementPositionAndAttributes } from './get_element_position_data'; +import { getNumberOfItems } from './get_number_of_items'; +import { getRenderErrors } from './get_render_errors'; +import { getScreenshots } from './get_screenshots'; +import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; +import { openUrl } from './open_url'; +import { waitForRenderComplete } from './wait_for_render'; +import { waitForVisualizations } from './wait_for_visualizations'; + +export class ScreenshotObservableHandler { + private conditionalHeaders: ScreenshotObservableOpts['conditionalHeaders']; + private layout: ScreenshotObservableOpts['layout']; + private logger: ScreenshotObservableOpts['logger']; + private waitErrorRegistered = false; + + constructor( + private readonly driver: HeadlessChromiumDriver, + opts: ScreenshotObservableOpts, + private timeouts: PhaseTimeouts + ) { + this.conditionalHeaders = opts.conditionalHeaders; + this.layout = opts.layout; + this.logger = opts.logger; + } + + /* + * Decorates a TimeoutError with context of the phase that has timed out. + */ + public waitUntil(phase: PhaseInstance) { + const { timeoutValue, label, configValue } = phase; + return (source: Rx.Observable) => { + return source.pipe( + timeout(timeoutValue), + catchError((error: string | Error) => { + if (this.waitErrorRegistered) { + throw error; // do not create a stack of errors within the error + } + + this.logger.error(error); + let throwError = new Error(`The "${label}" phase encountered an error: ${error}`); + + if (error instanceof Rx.TimeoutError) { + throwError = new Error( + `The "${label}" phase took longer than` + + ` ${numberToDuration(timeoutValue).asSeconds()} seconds.` + + ` You may need to increase "${configValue}": ${error}` + ); + } + + this.waitErrorRegistered = true; + this.logger.error(throwError); + throw throwError; + }) + ); + }; + } + + private openUrl(index: number, urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple) { + return mergeMap(() => + openUrl( + this.timeouts.openUrl.timeoutValue, + this.driver, + index, + urlOrUrlLocatorTuple, + this.conditionalHeaders, + this.logger + ) + ); + } + + private waitForElements() { + const driver = this.driver; + const waitTimeout = this.timeouts.waitForElements.timeoutValue; + return (withPageOpen: Rx.Observable) => + withPageOpen.pipe( + mergeMap(() => getNumberOfItems(waitTimeout, driver, this.layout, this.logger)), + mergeMap(async (itemsCount) => { + // set the viewport to the dimentions from the job, to allow elements to flow into the expected layout + const viewport = this.layout.getViewport(itemsCount) || getDefaultViewPort(); + await Promise.all([ + driver.setViewport(viewport, this.logger), + waitForVisualizations(waitTimeout, driver, itemsCount, this.layout, this.logger), + ]); + }) + ); + } + + private completeRender(apmTrans: apm.Transaction | null) { + const driver = this.driver; + const layout = this.layout; + const logger = this.logger; + + return (withElements: Rx.Observable) => + withElements.pipe( + mergeMap(async () => { + // Waiting till _after_ elements have rendered before injecting our CSS + // allows for them to be displayed properly in many cases + await injectCustomCss(driver, layout, logger); + + const apmPositionElements = apmTrans?.startSpan('position_elements', 'correction'); + // position panel elements for print layout + await layout.positionElements?.(driver, logger); + apmPositionElements?.end(); + + await waitForRenderComplete(this.timeouts.loadDelay, driver, layout, logger); + }), + mergeMap(() => + Promise.all([ + getTimeRange(driver, layout, logger), + getElementPositionAndAttributes(driver, layout, logger), + getRenderErrors(driver, layout, logger), + ]).then(([timeRange, elementsPositionAndAttributes, renderErrors]) => ({ + elementsPositionAndAttributes, + timeRange, + renderErrors, + })) + ) + ); + } + + public setupPage( + index: number, + urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, + apmTrans: apm.Transaction | null + ) { + return (initial: Rx.Observable) => + initial.pipe( + this.openUrl(index, urlOrUrlLocatorTuple), + this.waitUntil(this.timeouts.openUrl), + this.waitForElements(), + this.waitUntil(this.timeouts.waitForElements), + this.completeRender(apmTrans), + this.waitUntil(this.timeouts.renderComplete) + ); + } + + public getScreenshots() { + return (withRenderComplete: Rx.Observable) => + withRenderComplete.pipe( + mergeMap(async (data: PageSetupResults): Promise => { + this.checkPageIsOpen(); // fail the report job if the browser has closed + + const elements = + data.elementsPositionAndAttributes ?? + getDefaultElementPosition(this.layout.getViewport(1)); + const screenshots = await getScreenshots(this.driver, elements, this.logger); + const { timeRange, error: setupError } = data; + + return { + timeRange, + screenshots, + error: setupError, + elementsPositionAndAttributes: elements, + }; + }) + ); + } + + public checkPageIsOpen() { + if (!this.driver.isPageOpen()) { + throw getChromiumDisconnectedError(); + } + } +} + +const DEFAULT_SCREENSHOT_CLIP_HEIGHT = 1200; +const DEFAULT_SCREENSHOT_CLIP_WIDTH = 1800; + +const getDefaultElementPosition = (dimensions: { height?: number; width?: number } | null) => { + const height = dimensions?.height || DEFAULT_SCREENSHOT_CLIP_HEIGHT; + const width = dimensions?.width || DEFAULT_SCREENSHOT_CLIP_WIDTH; + + return [ + { + position: { + boundingClientRect: { top: 0, left: 0, height, width }, + scroll: { x: 0, y: 0 }, + }, + attributes: {}, + }, + ]; +}; + +/* + * If Kibana is showing a non-HTML error message, the viewport might not be + * provided by the browser. + */ +const getDefaultViewPort = () => ({ + height: DEFAULT_SCREENSHOT_CLIP_HEIGHT, + width: DEFAULT_SCREENSHOT_CLIP_WIDTH, + zoom: 1, +}); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index 588cd792bdf06..63a5e80289e3e 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -6,21 +6,25 @@ */ import { i18n } from '@kbn/i18n'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; import { LevelLogger, startTrace } from '../'; -import { durationToNumber } from '../../../common/schema_utils'; +import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; import { HeadlessChromiumDriver } from '../../browsers'; import { ConditionalHeaders } from '../../export_types/common'; -import { CaptureConfig } from '../../types'; +import { DEFAULT_PAGELOAD_SELECTOR } from './constants'; export const openUrl = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, + index: number, urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, - waitForSelector: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { + // If we're moving to another page in the app, we'll want to wait for the app to tell us + // it's loaded the next page. + const page = index + 1; + const waitForSelector = page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + const endTrace = startTrace('open_url', 'wait'); let url: string; let locator: undefined | LocatorParams; @@ -32,14 +36,13 @@ export const openUrl = async ( } try { - const timeout = durationToNumber(captureConfig.timeouts.openUrl); await browser.open(url, { conditionalHeaders, waitForSelector, timeout, locator }, logger); } catch (err) { logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntLoadKibana', { - defaultMessage: `An error occurred when trying to open the Kibana URL. You may need to increase '{configKey}'. {error}`, - values: { configKey: 'xpack.reporting.capture.timeouts.openUrl', error: err }, + defaultMessage: `An error occurred when trying to open the Kibana URL: {error}`, + values: { error: err }, }) ); } diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts index f8293bfce3346..1ac4b58b61507 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_render.ts @@ -6,15 +6,13 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; -import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LevelLogger, startTrace } from '../'; +import { HeadlessChromiumDriver } from '../../browsers'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( - captureConfig: CaptureConfig, + loadDelay: number, browser: HeadlessChromiumDriver, layout: LayoutInstance, logger: LevelLogger @@ -69,7 +67,7 @@ export const waitForRenderComplete = async ( return Promise.all(renderedTasks).then(hackyWaitForVisualizations); }, - args: [layout.selectors.renderComplete, durationToNumber(captureConfig.loadDelay)], + args: [layout.selectors.renderComplete, loadDelay], }, { context: CONTEXT_WAITFORRENDER }, logger diff --git a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts index 0ab274da7e1cf..d4bf1db2a0c5a 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/wait_for_visualizations.ts @@ -6,10 +6,8 @@ */ import { i18n } from '@kbn/i18n'; -import { durationToNumber } from '../../../common/schema_utils'; import { LevelLogger, startTrace } from '../'; import { HeadlessChromiumDriver } from '../../browsers'; -import { CaptureConfig } from '../../types'; import { LayoutInstance } from '../layouts'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -25,7 +23,7 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - captureConfig: CaptureConfig, + timeout: number, browser: HeadlessChromiumDriver, toEqual: number, layout: LayoutInstance, @@ -42,7 +40,6 @@ export const waitForVisualizations = async ( ); try { - const timeout = durationToNumber(captureConfig.timeouts.renderComplete); await browser.waitFor( { fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual, timeout }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, @@ -54,12 +51,8 @@ export const waitForVisualizations = async ( logger.error(err); throw new Error( i18n.translate('xpack.reporting.screencapture.couldntFinishRendering', { - defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. You may need to increase '{configKey}'. {error}`, - values: { - count: toEqual, - configKey: 'xpack.reporting.capture.timeouts.renderComplete', - error: err, - }, + defaultMessage: `An error occurred when trying to wait for {count} visualizations to finish rendering. {error}`, + values: { count: toEqual, error: err }, }) ); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 96c57ef71eeb8..83c70d1664ac8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -19599,13 +19599,10 @@ "xpack.reporting.registerFeature.reportingDescription": "Discover、可視化、ダッシュボードから生成されたレポートを管理します。", "xpack.reporting.registerFeature.reportingTitle": "レポート", "xpack.reporting.screencapture.browserWasClosed": "ブラウザーは予期せず終了しました。詳細については、サーバーログを確認してください。", - "xpack.reporting.screencapture.couldntFinishRendering": "{count} 件のビジュアライゼーションのレンダリングが完了するのを待つ間にエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", - "xpack.reporting.screencapture.couldntLoadKibana": "Kibana URL を開こうとするときにエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.injectCss": "Kibana CSS をレポート用に更新しようとしたときにエラーが発生しました。{error}", "xpack.reporting.screencapture.injectingCss": "カスタム css の投入中", "xpack.reporting.screencapture.logWaitingForElements": "要素または項目のカウント属性を待ち、または見つからないため中断", "xpack.reporting.screencapture.noElements": "ビジュアライゼーションパネルのページを読み取る間にエラーが発生しました:パネルが見つかりませんでした。", - "xpack.reporting.screencapture.readVisualizationsError": "ビジュアライゼーションパネル情報のページを読み取ろうとしたときにエラーが発生しました。「{configKey}」を増やす必要があるかもしれません。 {error}", "xpack.reporting.screencapture.renderIsComplete": "レンダリングが完了しました", "xpack.reporting.screencapture.screenshotsTaken": "撮影したスクリーンショット:{numScreenhots}", "xpack.reporting.screencapture.takingScreenshots": "スクリーンショットの撮影中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 81e03b2024fed..a6f8d18aed101 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19882,13 +19882,10 @@ "xpack.reporting.registerFeature.reportingDescription": "管理您从 Discover、Visualize 和 Dashboard 生成的报告。", "xpack.reporting.registerFeature.reportingTitle": "Reporting", "xpack.reporting.screencapture.browserWasClosed": "浏览器已意外关闭!有关更多信息,请查看服务器日志。", - "xpack.reporting.screencapture.couldntFinishRendering": "尝试等候 {count} 个可视化完成渲染时发生错误。您可能需要增加“{configKey}”。{error}", - "xpack.reporting.screencapture.couldntLoadKibana": "尝试打开 Kibana URL 时发生了错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.injectCss": "尝试为 Reporting 更新 Kibana CSS 时发生错误。{error}", "xpack.reporting.screencapture.injectingCss": "正在注入定制 css", "xpack.reporting.screencapture.logWaitingForElements": "等候元素或项目计数属性;或未发现要中断", "xpack.reporting.screencapture.noElements": "读取页面以获取可视化面板时发生了错误:未找到任何面板。", - "xpack.reporting.screencapture.readVisualizationsError": "尝试页面以获取可视化面板信息时发生了错误。您可能需要增加“{configKey}”。{error}", "xpack.reporting.screencapture.renderIsComplete": "渲染已完成", "xpack.reporting.screencapture.screenshotsTaken": "已捕获的屏幕截图:{numScreenhots}", "xpack.reporting.screencapture.takingScreenshots": "正在捕获屏幕截图", From 7ed7991981462c10e41167a4d2aa60c565f07f31 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 15:52:42 -0400 Subject: [PATCH 21/54] Adds missing DOM.Iterable (#115218) (#115419) ## Summary It was brought to our attention from security solutions that developers found we are missing the iterators and entries and others from TypeScript that are available when you include within `lib` `DOM.iterable`. For example without it you cannot use `entries` like this: Screen Shot 2021-10-15 at 9 10 17 AM Until you add it within he base config. Developers are indicating they noticed that workarounds such as lodash or casting is possible but I think this is the wrong direction to go and it's just an oversight that we missed adding the `DOM.iterable` unless someone tells us otherwise. If it is intentional to omit I would like to add docs to the `tsconfig.base.json` about why we omit it for programmers to understand the intention and why we should discourage use or recommend a library such as lodash instead. Co-authored-by: Frank Hassanabad --- tsconfig.base.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.base.json b/tsconfig.base.json index 9de81f68110c1..18c0ad38f4601 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,8 @@ "lib": [ "esnext", // includes support for browser APIs - "dom" + "dom", + "DOM.Iterable" ], // Node 8 should support everything output by esnext, we override this // in webpack with loader-level compiler options From 2f561077f0516f775f1747a7584aa861b2e51bcd Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 16:15:52 -0400 Subject: [PATCH 22/54] [7.x] [Fleet] added support for installing tag saved objects (#114110) (#114446) * [Fleet] added support for installing tag saved objects (#114110) * added tag saved objects to assets * fixed review comments * added translation to constants * added missing icon type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * fixed backport Co-authored-by: juliaElastic <90178898+juliaElastic@users.noreply.github.com> Co-authored-by: Julia Bardi --- .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../components/assets_facet_group.stories.tsx | 1 + .../integrations/sections/epm/constants.tsx | 65 ++++++++++++++----- .../services/epm/kibana/assets/install.ts | 2 + ...kage_policies_to_agent_permissions.test.ts | 3 + .../context/fixtures/integration.nginx.ts | 1 + .../context/fixtures/integration.okta.ts | 1 + .../apis/epm/install_remove_assets.ts | 11 ++++ .../apis/epm/update_assets.ts | 5 ++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.1.0/kibana/tag/sample_tag.json | 13 ++++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.2.0/kibana/tag/sample_tag.json | 13 ++++ .../error_handling/0.2.0/manifest.yml | 1 + 15 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 275cf237a9621..e554eb925c38a 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -33,6 +33,7 @@ describe('Fleet - packageToPackagePolicy', () => { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 42bc93403b2bb..df4cdec184dc8 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -69,6 +69,7 @@ export enum KibanaAssetType { lens = 'lens', securityRule = 'security_rule', mlModule = 'ml_module', + tag = 'tag', } /* @@ -83,6 +84,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + tag = 'tag', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index d98f2b2408d56..a7fa069e77a69 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -36,6 +36,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx index 8e900e625215f..25604bb6b984d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -6,6 +6,7 @@ */ import type { IconType } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import type { ServiceName } from '../../types'; import { ElasticsearchAssetType, KibanaAssetType } from '../../types'; @@ -22,21 +23,54 @@ export const DisplayedAssets: ServiceNameToAssetTypes = { export type DisplayedAssetType = ElasticsearchAssetType | KibanaAssetType | 'view'; export const AssetTitleMap: Record = { - dashboard: 'Dashboards', - ilm_policy: 'ILM policies', - ingest_pipeline: 'Ingest pipelines', - transform: 'Transforms', - index_pattern: 'Index patterns', - index_template: 'Index templates', - component_template: 'Component templates', - search: 'Saved searches', - visualization: 'Visualizations', - map: 'Maps', - data_stream_ilm_policy: 'Data stream ILM policies', - lens: 'Lens', - security_rule: 'Security rules', - ml_module: 'ML modules', - view: 'Views', + dashboard: i18n.translate('xpack.fleet.epm.assetTitles.dashboards', { + defaultMessage: 'Dashboards', + }), + ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.ilmPolicies', { + defaultMessage: 'ILM policies', + }), + ingest_pipeline: i18n.translate('xpack.fleet.epm.assetTitles.ingestPipelines', { + defaultMessage: 'Ingest pipelines', + }), + transform: i18n.translate('xpack.fleet.epm.assetTitles.transforms', { + defaultMessage: 'Transforms', + }), + index_pattern: i18n.translate('xpack.fleet.epm.assetTitles.indexPatterns', { + defaultMessage: 'Index patterns', + }), + index_template: i18n.translate('xpack.fleet.epm.assetTitles.indexTemplates', { + defaultMessage: 'Index templates', + }), + component_template: i18n.translate('xpack.fleet.epm.assetTitles.componentTemplates', { + defaultMessage: 'Component templates', + }), + search: i18n.translate('xpack.fleet.epm.assetTitles.savedSearches', { + defaultMessage: 'Saved searches', + }), + visualization: i18n.translate('xpack.fleet.epm.assetTitles.visualizations', { + defaultMessage: 'Visualizations', + }), + map: i18n.translate('xpack.fleet.epm.assetTitles.maps', { + defaultMessage: 'Maps', + }), + data_stream_ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.dataStreamILM', { + defaultMessage: 'Data stream ILM policies', + }), + lens: i18n.translate('xpack.fleet.epm.assetTitles.lens', { + defaultMessage: 'Lens', + }), + security_rule: i18n.translate('xpack.fleet.epm.assetTitles.securityRules', { + defaultMessage: 'Security rules', + }), + ml_module: i18n.translate('xpack.fleet.epm.assetTitles.mlModules', { + defaultMessage: 'ML modules', + }), + view: i18n.translate('xpack.fleet.epm.assetTitles.views', { + defaultMessage: 'Views', + }), + tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { + defaultMessage: 'Tag', + }), }; export const ServiceTitleMap: Record = { @@ -53,6 +87,7 @@ export const AssetIcons: Record = { lens: 'lensApp', security_rule: 'securityApp', ml_module: 'mlApp', + tag: 'tagApp', }; export const ServiceIcons: Record = { diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 0f2d7b6679bf9..50c0239cd8c56 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -39,6 +39,7 @@ const KibanaSavedObjectTypeMapping: Record { diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 9f8ac01afe6c9..845e4f1d2670e 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -97,6 +97,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -207,6 +208,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -323,6 +325,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts index 50262b73a6a41..e0179897a59c7 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const response: GetInfoResponse['response'] = { lens: [], map: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts index efef00579f4bd..387161171485b 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const response: GetInfoResponse['response'] = { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 7e48ed9d297c7..4b3ae3fc9e50b 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -374,6 +374,7 @@ const expectAssetsInstalled = ({ id: 'sample_dashboard', }); expect(resDashboard.id).equal('sample_dashboard'); + expect(resDashboard.references.map((ref: any) => ref.id).includes('sample_tag')).equal(true); const resDashboard2 = await kibanaServer.savedObjects.get({ type: 'dashboard', id: 'sample_dashboard2', @@ -404,6 +405,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resTag = await kibanaServer.savedObjects.get({ + type: 'tag', + id: 'sample_tag', + }); + expect(resTag.id).equal('sample_tag'); const resIndexPattern = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'test-*', @@ -481,6 +487,10 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_tag', + type: 'tag', + }, { id: 'sample_visualization', type: 'visualization', @@ -562,6 +572,7 @@ const expectAssetsInstalled = ({ { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, + { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index f46dcdb761e6d..5282312164148 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -339,6 +339,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_ml_module', type: 'ml-module', }, + { + id: 'sample_tag', + type: 'tag', + }, ], installed_es: [ { @@ -418,6 +422,7 @@ export default function (providerContext: FtrProviderContext) { { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, { id: '2e56f08b-1d06-55ed-abee-4708e1ccf0aa', type: 'epm-packages-assets' }, { id: '4035007b-9c33-5227-9803-2de8a17523b5', type: 'epm-packages-assets' }, + { id: 'e6ae7d31-6920-5408-9219-91ef1662044b', type: 'epm-packages-assets' }, { id: 'c7bf1a39-e057-58a0-afde-fb4b48751d8c', type: 'epm-packages-assets' }, { id: '8c665f28-a439-5f43-b5fd-8fda7b576735', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json index 7f416c26cc9aa..c75dd7673dc38 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search", "name": "panel_1", "type": "search" }, { "id": "sample_search", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json new file mode 100644 index 0000000000000..7eab57414ff2c --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json @@ -0,0 +1,13 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [] +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json index 4513c07f27786..1215a934c6368 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search2", "name": "panel_1", "type": "search" }, { "id": "sample_search2", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json new file mode 100644 index 0000000000000..7eab57414ff2c --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json @@ -0,0 +1,13 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [] +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml index c92f0ab5ae7f3..c473ce29b87d5 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml @@ -17,3 +17,4 @@ requirement: icons: - src: '/img/logo_overrides_64_color.svg' size: '16x16' + type: 'image/svg+xml' From aa0412e86649d393f166bd8c592e4bd94277049f Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Mon, 18 Oct 2021 16:17:35 -0400 Subject: [PATCH 23/54] Conditionally sets ignore_throttled only when search:includeFrozen is true (#114381) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../framework/kibana_framework_adapter.ts | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts index 25763824a336d..2356d70862851 100644 --- a/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/framework/kibana_framework_adapter.ts @@ -5,37 +5,41 @@ * 2.0. */ +import { estypes } from '@elastic/elasticsearch'; import { IndicesExistsAlias, IndicesGet, MlGetBuckets, } from '@elastic/elasticsearch/api/requestParams'; import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; -import { estypes } from '@elastic/elasticsearch'; -import { SavedObjectsClientContract, ElasticsearchClient } from 'src/core/server'; -import { - InfraRouteConfig, - InfraServerPluginSetupDeps, - CallWithRequestParams, - InfraDatabaseSearchResponse, - InfraDatabaseMultiResponse, - InfraDatabaseFieldCapsResponse, - InfraDatabaseGetIndicesResponse, - InfraDatabaseGetIndicesAliasResponse, -} from './adapter_types'; -import { TSVBMetricModel } from '../../../../common/inventory_models/types'; +import { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { CoreSetup, IRouter, KibanaRequest, + RequestHandler, RouteMethod, } from '../../../../../../../src/core/server'; -import { RequestHandler } from '../../../../../../../src/core/server'; -import { InfraConfig } from '../../../plugin'; -import type { InfraPluginRequestHandlerContext } from '../../../types'; import { UI_SETTINGS } from '../../../../../../../src/plugins/data/server'; import { TimeseriesVisData } from '../../../../../../../src/plugins/vis_types/timeseries/server'; -import { InfraServerPluginStartDeps } from './adapter_types'; +import { TSVBMetricModel } from '../../../../common/inventory_models/types'; +import { InfraConfig } from '../../../plugin'; +import type { InfraPluginRequestHandlerContext } from '../../../types'; +import { + CallWithRequestParams, + InfraDatabaseFieldCapsResponse, + InfraDatabaseGetIndicesAliasResponse, + InfraDatabaseGetIndicesResponse, + InfraDatabaseMultiResponse, + InfraDatabaseSearchResponse, + InfraRouteConfig, + InfraServerPluginSetupDeps, + InfraServerPluginStartDeps, +} from './adapter_types'; + +interface FrozenIndexParams { + ignore_throttled?: boolean; +} export class KibanaFramework { public router: IRouter; @@ -133,7 +137,7 @@ export class KibanaFramework { ) { const { elasticsearch, uiSettings } = requestContext.core; - const includeFrozen = await uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); + const includeFrozen = await uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); if (endpoint === 'msearch') { const maxConcurrentShardRequests = await uiSettings.client.get( UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS @@ -143,11 +147,17 @@ export class KibanaFramework { } } - const frozenIndicesParams = ['search', 'msearch'].includes(endpoint) - ? { - ignore_throttled: !includeFrozen, - } - : {}; + // Only set the "ignore_throttled" value (to false) if the Kibana setting + // for "search:includeFrozen" is true (i.e. don't ignore throttled indices, a triple negative!) + // More information: + // - https://github.com/elastic/kibana/issues/113197 + // - https://github.com/elastic/elasticsearch/pull/77479 + // + // NOTE: these params only need to be spread onto the search and msearch calls below + const frozenIndicesParams: FrozenIndexParams = {}; + if (includeFrozen) { + frozenIndicesParams.ignore_throttled = false; + } let apiResult; switch (endpoint) { @@ -166,37 +176,31 @@ export class KibanaFramework { case 'fieldCaps': apiResult = elasticsearch.client.asCurrentUser.fieldCaps({ ...params, - ...frozenIndicesParams, }); break; case 'indices.existsAlias': apiResult = elasticsearch.client.asCurrentUser.indices.existsAlias({ ...params, - ...frozenIndicesParams, } as IndicesExistsAlias); break; case 'indices.getAlias': apiResult = elasticsearch.client.asCurrentUser.indices.getAlias({ ...params, - ...frozenIndicesParams, }); break; case 'indices.get': apiResult = elasticsearch.client.asCurrentUser.indices.get({ ...params, - ...frozenIndicesParams, } as IndicesGet); break; case 'transport.request': apiResult = elasticsearch.client.asCurrentUser.transport.request({ ...params, - ...frozenIndicesParams, } as TransportRequestParams); break; case 'ml.getBuckets': apiResult = elasticsearch.client.asCurrentUser.ml.getBuckets({ ...params, - ...frozenIndicesParams, } as MlGetBuckets); break; } From 7f4f43baee2071bde3ad3ad6830b1f0fe7ace964 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 16:41:15 -0400 Subject: [PATCH 24/54] [Cases] ServiceNow connectors UI changes (#114234) (#115358) * POC * Before and after saving connector callbacks * Draft callbacks on SN * Migrate legacy connectors * Add deprecated connector * Fix callbacks types * Pass isEdit to connector forms * Get application info hook * Validate instance on save * Support both legacy and new app * Seperate SIR * Log application version & and throw otherwise * Deprecated tooltip cases * Deprecated tooltip alerts * Improve message * Improve translation * Change to elastic table & fix types * Add callbacks to add modal * Pass new props to tests * Change health api url to production * Better installation message * Migrate connectors functionality * Change migration version to 7.16 * Fix bug * Improve message * Use feature flag * Create credentials component * Add form to migration modal * Improve installation callout * Improve deprecated callout * Improve modal * Improve application required modal * Improve SN form * Support both connectors * Support correlation attributes * Use same component for SIR * Prevent using legacy connectors when creating a case * Add observables * Unique observables * Push only if there are observables * Change labels to plural * Pass correlation ID and value * Show errors on the callout * Improve alerts tooltip * Improve cases tooltip * Warning callout on cases configuration page * Fix tooltip content * Add help text * Change from string to array * Fix i18n * Fix spelling * Update incidents for ITSM * Update incidents for SIR * Fix types * Fix backend tests * Fix frontend tests * Add service tests * Fix i18n * Fix cypress test * Improve ServiceNow intergration tests * Fix cases integration tests * Fix triggers actions ui end to end test * Fix tests * Rename modal * Show error message on modal * Create useOldConnector helper * Show the update incident toggle only on new connectors * Add observables for old connectors * Fix error when obs are empty * Enable SIR for alerts * Fix types * Improve combineObservables * Add test for the sir api * Add test for the sir service * Add documentation * PR feedback * Improve cases deprecated callouts * Improve observables format * Add integration tests for SIR * Fix doc error * Add config tests * Add getIncident tests * Add util tests * Add migration tests * Add tests for connectors and improve callouts * Add more tests * Add more UI tests * update connector modal to flyout * PR feedback * Test CI * restore auth callout * edit connector form spacing * Improve integration tests * Add 8 pixels to the left of the connector icon * update switch to checkboxes * case detail ui * Seperate ServiceNow integration tests * Remove observables fields * Add correlation values * Fix merge * add deprecated text in the dropdown * update card icon to the right * new update connetor test and other tests fixes * PR feedback * Remove observables from docs * Remove unused translations * Using eui theme for styling * Content feeback * Add more unit tests * Fix i18n * Fix types * Fixes * Fixes * test properly * fix duplicated translation * Simplify tooltip * Writing feedback Co-authored-by: Christos Nasikas Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Jonathan Buttner Co-authored-by: Sergi Massaneda Co-authored-by: Christos Nasikas Co-authored-by: Jonathan Buttner --- .../action-types/servicenow-sir.asciidoc | 6 +- .../action-types/servicenow.asciidoc | 2 + .../images/servicenow-sir-params-test.png | Bin 46762 -> 194080 bytes .../public/doc_links/doc_links_service.ts | 1 + .../configure_cases/connectors.test.tsx | 8 +- .../connectors_dropdown.test.tsx | 12 +- .../configure_cases/connectors_dropdown.tsx | 16 +- .../configure_cases/translations.ts | 8 +- .../public/components/connectors/card.tsx | 26 +- .../connectors/deprecated_callout.test.tsx | 8 +- .../connectors/deprecated_callout.tsx | 5 +- .../servicenow_itsm_case_fields.tsx | 2 +- .../servicenow/servicenow_sir_case_fields.tsx | 2 +- .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - .../application_required_callout.tsx | 2 +- .../builtin_action_types/servicenow/config.ts | 9 - .../servicenow/credentials.tsx | 161 +------- .../servicenow/credentials_api_url.tsx | 89 +++++ .../servicenow/credentials_auth.tsx | 110 ++++++ .../servicenow/deprecated_callout.test.tsx | 2 +- .../servicenow/deprecated_callout.tsx | 24 +- .../servicenow/helpers.ts | 2 + .../servicenow/installation_callout.test.tsx | 2 +- .../servicenow/servicenow_connectors.test.tsx | 353 +++++++++++++++--- .../servicenow/servicenow_connectors.tsx | 64 ++-- .../servicenow_itsm_params.test.tsx | 33 +- .../servicenow/servicenow_itsm_params.tsx | 82 ++-- .../servicenow/servicenow_sir_params.test.tsx | 41 +- .../servicenow/servicenow_sir_params.tsx | 128 +++---- .../servicenow/sn_store_button.test.tsx | 30 +- .../servicenow/sn_store_button.tsx | 12 +- .../servicenow/translations.ts | 93 +---- .../servicenow/update_connector.test.tsx | 181 +++++++++ .../servicenow/update_connector.tsx | 208 +++++++++++ .../servicenow/update_connector_modal.tsx | 156 -------- .../components/actions_connectors_list.tsx | 47 ++- 37 files changed, 1245 insertions(+), 688 deletions(-) delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx delete mode 100644 x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx diff --git a/docs/management/connectors/action-types/servicenow-sir.asciidoc b/docs/management/connectors/action-types/servicenow-sir.asciidoc index 4556746284d5b..2fa49fe552c2e 100644 --- a/docs/management/connectors/action-types/servicenow-sir.asciidoc +++ b/docs/management/connectors/action-types/servicenow-sir.asciidoc @@ -72,13 +72,11 @@ image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow Se ServiceNow SecOps actions have the following configuration properties. Short description:: A short description for the incident, used for searching the contents of the knowledge base. -Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident. -Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident. -Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident. -Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident. Priority:: The priority of the incident. Category:: The category of the incident. Subcategory:: The subcategory of the incident. +Correlation ID:: All actions sharing this ID will be associated with the same ServiceNow security incident. If an incident exists in ServiceNow with the same correlation ID the security incident will be updated. Default value: `:`. +Correlation Display:: A descriptive label of the alert for correlation purposes in ServiceNow. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/action-types/servicenow.asciidoc b/docs/management/connectors/action-types/servicenow.asciidoc index cf5244a9e3f9e..f7c3187f3f024 100644 --- a/docs/management/connectors/action-types/servicenow.asciidoc +++ b/docs/management/connectors/action-types/servicenow.asciidoc @@ -76,6 +76,8 @@ Severity:: The severity of the incident. Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question. Category:: The category of the incident. Subcategory:: The category of the incident. +Correlation ID:: All actions sharing this ID will be associated with the same ServiceNow incident. If an incident exists in ServiceNow with the same correlation ID the incident will be updated. Default value: `:`. +Correlation Display:: A descriptive label of the alert for correlation purposes in ServiceNow. Short description:: A short description for the incident, used for searching the contents of the knowledge base. Description:: The details about the incident. Additional comments:: Additional information for the client, such as how to troubleshoot the issue. diff --git a/docs/management/connectors/images/servicenow-sir-params-test.png b/docs/management/connectors/images/servicenow-sir-params-test.png index 80103a4272bfacd0a6ccb23bb138b98efe327f51..a2bf8761a88240ff2ac77f2a342d14d2bba4119a 100644 GIT binary patch literal 194080 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

o?LeGDRBKoXxv+T*5)aV$_r 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

}*>kZ4 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 literal 46762 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ì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 ay { } ); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); - expect( - screen.getByText( - 'This connector type is deprecated. Create a new connector or update this connector' - ) - ).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); + expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); }); test('it does not shows the deprecated callout when the connector is none', async () => { diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx index 34422392b7efa..6f05f9f940d25 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.test.tsx @@ -190,17 +190,17 @@ describe('ConnectorsDropdown', () => { > My Connector + (deprecated) - @@ -293,7 +293,9 @@ describe('ConnectorsDropdown', () => { wrapper: ({ children }) => {children}, }); - const tooltips = screen.getAllByLabelText('Deprecated connector'); + const tooltips = screen.getAllByLabelText( + 'This connector is deprecated. Update it, or create a new one.' + ); expect(tooltips[0]).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx index f21b3ab3d544f..c5fe9c7470745 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/connectors_dropdown.tsx @@ -14,6 +14,7 @@ import { ActionConnector } from '../../containers/configure/types'; import * as i18n from './translations'; import { useKibana } from '../../common/lib/kibana'; import { getConnectorIcon, isLegacyConnector } from '../utils'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; export interface Props { connectors: ActionConnector[]; @@ -57,6 +58,11 @@ const addNewConnector = { 'data-test-subj': 'dropdown-connector-add-connector', }; +const StyledEuiIconTip = euiStyled(EuiIconTip)` + margin-left: ${({ theme }) => theme.eui.euiSizeS} + margin-bottom: 0 !important; +`; + const ConnectorsDropdownComponent: React.FC = ({ connectors, disabled, @@ -87,16 +93,18 @@ const ConnectorsDropdownComponent: React.FC = ({ /> - {connector.name} + + {connector.name} + {isLegacyConnector(connector) && ` (${i18n.DEPRECATED_TOOLTIP_TEXT})`} + {isLegacyConnector(connector) && ( - diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 4a775c78d4ab8..26b45a8c3a250 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -163,16 +163,16 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string => defaultMessage: 'Update { connectorName }', }); -export const DEPRECATED_TOOLTIP_TITLE = i18n.translate( - 'xpack.cases.configureCases.deprecatedTooltipTitle', +export const DEPRECATED_TOOLTIP_TEXT = i18n.translate( + 'xpack.cases.configureCases.deprecatedTooltipText', { - defaultMessage: 'Deprecated connector', + defaultMessage: 'deprecated', } ); export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate( 'xpack.cases.configureCases.deprecatedTooltipContent', { - defaultMessage: 'Please update your connector', + defaultMessage: 'This connector is deprecated. Update it, or create a new one.', } ); diff --git a/x-pack/plugins/cases/public/components/connectors/card.tsx b/x-pack/plugins/cases/public/components/connectors/card.tsx index 86cd90dafb376..ec4b52c54f707 100644 --- a/x-pack/plugins/cases/public/components/connectors/card.tsx +++ b/x-pack/plugins/cases/public/components/connectors/card.tsx @@ -6,7 +6,7 @@ */ import React, { memo, useMemo } from 'react'; -import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; +import { EuiCard, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; import { ConnectorTypes } from '../../../common'; @@ -59,16 +59,20 @@ const ConnectorCardDisplay: React.FC = ({ <> {isLoading && } {!isLoading && ( - + + + + + {icon} + )} ); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx index 6b1475e3c4bd0..367609df3c887 100644 --- a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.test.tsx @@ -12,12 +12,8 @@ import { DeprecatedCallout } from './deprecated_callout'; describe('DeprecatedCallout', () => { test('it renders correctly', () => { render(); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); - expect( - screen.getByText( - 'This connector type is deprecated. Create a new connector or update this connector' - ) - ).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); + expect(screen.getByText('Update this connector, or create a new one.')).toBeInTheDocument(); expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass( 'euiCallOut euiCallOut--warning' ); diff --git a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx index 937f8406e218a..9337f2843506b 100644 --- a/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx +++ b/x-pack/plugins/cases/public/components/connectors/deprecated_callout.tsx @@ -12,15 +12,14 @@ import { i18n } from '@kbn/i18n'; const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate( 'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle', { - defaultMessage: 'Deprecated connector type', + defaultMessage: 'This connector type is deprecated', } ); const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate( 'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc', { - defaultMessage: - 'This connector type is deprecated. Create a new connector or update this connector', + defaultMessage: 'Update this connector, or create a new one.', } ); diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx index 096e450c736c1..e24b25065a1c8 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -157,7 +157,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx index a7b8aa7b27df5..d502b7382664b 100644 --- a/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx +++ b/x-pack/plugins/cases/public/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -173,7 +173,7 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent< {showConnectorWarning && ( - + )} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 83c70d1664ac8..113eae5d08e07 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25056,11 +25056,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle": "カテゴリー", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel": "追加のコメント", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel": "説明", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle": "デスティネーション IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel": "インパクト", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL が無効です。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle": "マルウェアハッシュ", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle": "マルウェアURL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "パスワード", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel": "優先度", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "ユーザー名とパスワードは暗号化されます。これらのフィールドの値を再入力してください。", @@ -25070,7 +25067,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "ユーザー名が必要です。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL は https:// から始める必要があります。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel": "深刻度", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle": "ソース IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle": "サブカテゴリー", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title": "インシデント", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel": "短い説明(必須)", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a6f8d18aed101..15933599699eb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25484,11 +25484,8 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.categoryTitle": "类别", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.commentsTextAreaFieldLabel": "其他注释", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.descriptionTextAreaFieldLabel": "描述", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle": "目标 IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.impactSelectFieldLabel": "影响", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.invalidApiUrlTextField": "URL 无效。", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle": "恶意软件哈希", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle": "恶意软件 URL", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.passwordTextFieldLabel": "密码", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.prioritySelectFieldLabel": "优先级", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel": "用户名和密码已加密。请为这些字段重新输入值。", @@ -25498,7 +25495,6 @@ "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredUsernameTextField": "“用户名”必填。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requireHttpsApiUrlTextField": "URL 必须以 https:// 开头。", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severitySelectFieldLabel": "严重性", - "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle": "源 IP", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.subcategoryTitle": "子类别", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title": "事件", "xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel": "简短描述(必填)", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx index 561dae95fe1b7..2faa5a9f4a5e0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/application_required_callout.tsx @@ -35,7 +35,7 @@ const ApplicationRequiredCalloutComponent: React.FC = ({ message }) => { ['action']; @@ -41,24 +30,6 @@ const CredentialsComponent: React.FC = ({ editActionSecrets, editActionConfig, }) => { - const { docLinks } = useKibana().services; - const { apiUrl } = action.config; - const { username, password } = action.secrets; - - const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); - const isUsernameInvalid = isFieldInvalid(username, errors.username); - const isPasswordInvalid = isFieldInvalid(password, errors.password); - - const handleOnChangeActionConfig = useCallback( - (key: string, value: string) => editActionConfig(key, value), - [editActionConfig] - ); - - const handleOnChangeSecretConfig = useCallback( - (key: string, value: string) => editActionSecrets(key, value), - [editActionSecrets] - ); - return ( <> @@ -66,45 +37,13 @@ const CredentialsComponent: React.FC = ({

{i18n.SN_INSTANCE_LABEL}

-

- - {i18n.SETUP_DEV_INSTANCE} - - ), - }} - /> -

- - - - handleOnChangeActionConfig('apiUrl', evt.target.value)} - onBlur={() => { - if (!apiUrl) { - editActionConfig('apiUrl', ''); - } - }} - disabled={isLoading} - /> - + @@ -115,75 +54,15 @@ const CredentialsComponent: React.FC = ({ - - - - - {getEncryptedFieldNotifyLabel( - !action.id, - 2, - action.isMissingSecrets ?? false, - i18n.REENTER_VALUES_LABEL - )} - - - - - - - - handleOnChangeSecretConfig('username', evt.target.value)} - onBlur={() => { - if (!username) { - editActionSecrets('username', ''); - } - }} - disabled={isLoading} - /> - - - - - - - - handleOnChangeSecretConfig('password', evt.target.value)} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } - }} - disabled={isLoading} - /> - - - + + + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx new file mode 100644 index 0000000000000..5ddef8bab6700 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_api_url.tsx @@ -0,0 +1,89 @@ +/* + * 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, { memo, useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormRow, EuiLink, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { useKibana } from '../../../../common/lib/kibana'; +import type { ActionConnectorFieldsProps } from '../../../../types'; +import * as i18n from './translations'; +import type { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; +} + +const CredentialsApiUrlComponent: React.FC = ({ + action, + errors, + isLoading, + readOnly, + editActionConfig, +}) => { + const { docLinks } = useKibana().services; + const { apiUrl } = action.config; + + const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl); + + const onChangeApiUrlEvent = useCallback( + (event?: React.ChangeEvent) => + editActionConfig('apiUrl', event?.target.value ?? ''), + [editActionConfig] + ); + + return ( + <> + +

+ + {i18n.SETUP_DEV_INSTANCE} + + ), + }} + /> +

+
+ + + { + if (!apiUrl) { + onChangeApiUrlEvent(); + } + }} + disabled={isLoading} + /> + + + ); +}; + +export const CredentialsApiUrl = memo(CredentialsApiUrlComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx new file mode 100644 index 0000000000000..c9fccc9faec99 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/credentials_auth.tsx @@ -0,0 +1,110 @@ +/* + * 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, { memo, useCallback } from 'react'; +import { EuiFormRow, EuiFieldText, EuiFieldPassword } from '@elastic/eui'; +import type { ActionConnectorFieldsProps } from '../../../../types'; +import * as i18n from './translations'; +import type { ServiceNowActionConnector } from './types'; +import { isFieldInvalid } from './helpers'; +import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label'; + +interface Props { + action: ActionConnectorFieldsProps['action']; + errors: ActionConnectorFieldsProps['errors']; + readOnly: boolean; + isLoading: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; +} + +const NUMBER_OF_FIELDS = 2; + +const CredentialsAuthComponent: React.FC = ({ + action, + errors, + isLoading, + readOnly, + editActionSecrets, +}) => { + const { username, password } = action.secrets; + + const isUsernameInvalid = isFieldInvalid(username, errors.username); + const isPasswordInvalid = isFieldInvalid(password, errors.password); + + const onChangeUsernameEvent = useCallback( + (event?: React.ChangeEvent) => + editActionSecrets('username', event?.target.value ?? ''), + [editActionSecrets] + ); + + const onChangePasswordEvent = useCallback( + (event?: React.ChangeEvent) => + editActionSecrets('password', event?.target.value ?? ''), + [editActionSecrets] + ); + + return ( + <> + + {getEncryptedFieldNotifyLabel( + !action.id, + NUMBER_OF_FIELDS, + action.isMissingSecrets ?? false, + i18n.REENTER_VALUES_LABEL + )} + + + { + if (!username) { + onChangeUsernameEvent(); + } + }} + disabled={isLoading} + /> + + + { + if (!password) { + onChangePasswordEvent(); + } + }} + disabled={isLoading} + /> + + + ); +}; + +export const CredentialsAuth = memo(CredentialsAuthComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx index 767b38ebcf6ad..0c125f3851636 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.test.tsx @@ -19,7 +19,7 @@ describe('DeprecatedCallout', () => { wrapper: ({ children }) => {children}, }); - expect(screen.getByText('Deprecated connector type')).toBeInTheDocument(); + expect(screen.getByText('This connector type is deprecated')).toBeInTheDocument(); }); test('it calls onMigrate when pressing the button', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx index 101d1572a67ad..faeeaa1bbbffe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/deprecated_callout.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui'; +import { EuiSpacer, EuiCallOut, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -26,23 +26,33 @@ const DeprecatedCalloutComponent: React.FC = ({ onMigrate }) => { title={i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutTitle', { - defaultMessage: 'Deprecated connector type', + defaultMessage: 'This connector type is deprecated', } )} > + update: ( + {i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate', { - defaultMessage: 'update this connector.', + defaultMessage: 'Update this connector,', } )} - + + ), + create: ( + + {i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutCreate', + { + defaultMessage: 'or create a new one.', + } + )} + ), }} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts index ca557b31c4f4f..0134133645bb3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/helpers.ts @@ -14,6 +14,8 @@ import { import { IErrorObject } from '../../../../../public/types'; import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types'; +export const DEFAULT_CORRELATION_ID = '{{rule.id}}:{{alert.id}}'; + export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => choices.map((choice) => ({ value: choice.value, text: choice.label })); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx index 8e1c1820920c5..ee63a546e6aa1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/installation_callout.test.tsx @@ -15,7 +15,7 @@ describe('DeprecatedCallout', () => { render(); expect( screen.getByText( - 'To use this connector, you must first install the Elastic App from the ServiceNow App Store' + 'To use this connector, first install the Elastic app from the ServiceNow app store.' ) ).toBeInTheDocument(); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 02f3ae47728ab..7c720148780a4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -6,29 +6,52 @@ */ import React from 'react'; +import { act } from '@testing-library/react'; import { mountWithIntl } from '@kbn/test/jest'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnectorFieldsSetCallbacks } from '../../../../types'; +import { updateActionConnector } from '../../../lib/action_connector_api'; import ServiceNowConnectorFields from './servicenow_connectors'; import { ServiceNowActionConnector } from './types'; +import { getAppInfo } from './api'; + jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../lib/action_connector_api'); +jest.mock('./api'); + +const useKibanaMock = useKibana as jest.Mocked; +const getAppInfoMock = getAppInfo as jest.Mock; +const updateActionConnectorMock = updateActionConnector as jest.Mock; describe('ServiceNowActionConnectorFields renders', () => { + const usesTableApiConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + isPreconfigured: false, + name: 'SN', + config: { + apiUrl: 'https://test/', + isLegacy: true, + }, + } as ServiceNowActionConnector; + + const usesImportSetApiConnector = { + ...usesTableApiConnector, + config: { + ...usesTableApiConnector.config, + isLegacy: false, + }, + } as ServiceNowActionConnector; + test('alerting servicenow connector fields are rendered', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.webhook', - isPreconfigured: false, - name: 'webhook', - config: { - apiUrl: 'https://test/', - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -41,30 +64,16 @@ describe('ServiceNowActionConnectorFields renders', () => { wrapper.find('[data-test-subj="connector-servicenow-username-form-input"]').length > 0 ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 ).toBeTruthy(); }); test('case specific servicenow connector fields is rendered', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.servicenow', - isPreconfigured: false, - name: 'servicenow', - config: { - apiUrl: 'https://test/', - isLegacy: false, - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -74,7 +83,8 @@ describe('ServiceNowActionConnectorFields renders', () => { isEdit={false} /> ); - expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy(); + + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').length > 0).toBeTruthy(); expect( wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').length > 0 ).toBeTruthy(); @@ -87,6 +97,7 @@ describe('ServiceNowActionConnectorFields renders', () => { config: {}, secrets: {}, } as ServiceNowActionConnector; + const wrapper = mountWithIntl( { config: {}, secrets: {}, } as ServiceNowActionConnector; + const wrapper = mountWithIntl( { }); test('should display a message on edit to re-enter credentials', () => { - const actionConnector = { - secrets: { - username: 'user', - password: 'pass', - }, - id: 'test', - actionTypeId: '.servicenow', - isPreconfigured: false, - name: 'servicenow', - config: { - apiUrl: 'https://test/', - }, - } as ServiceNowActionConnector; const wrapper = mountWithIntl( {}} editActionSecrets={() => {}} @@ -152,4 +151,268 @@ describe('ServiceNowActionConnectorFields renders', () => { expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0); expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toEqual(0); }); + + describe('Elastic certified ServiceNow application', () => { + const { services } = useKibanaMock(); + const applicationInfoData = { + name: 'Elastic', + scope: 'x_elas2_inc_int', + version: '1.0.0', + }; + + let beforeActionConnectorSaveFn: () => Promise; + const setCallbacks = (({ + beforeActionConnectorSave, + }: { + beforeActionConnectorSave: () => Promise; + }) => { + beforeActionConnectorSaveFn = beforeActionConnectorSave; + }) as ActionConnectorFieldsSetCallbacks; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should render the correct callouts when the connectors needs the application', () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should render the correct callouts if the connector uses the table API', () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={() => {}} + isEdit={false} + /> + ); + expect(wrapper.find('[data-test-subj="snInstallationCallout"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="snDeprecatedCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should get application information when saving the connector', async () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await act(async () => { + await beforeActionConnectorSaveFn(); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should NOT get application information when the connector uses the old API', async () => { + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await act(async () => { + await beforeActionConnectorSaveFn(); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(0); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeFalsy(); + }); + + test('should render error when save failed', async () => { + expect.assertions(4); + + const errorMessage = 'request failed'; + getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage)); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await expect( + // The async is needed so the act will finished before asserting for the callout + async () => await act(async () => await beforeActionConnectorSaveFn()) + ).rejects.toThrow(errorMessage); + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + + test('should render error when the response is a REST api error', async () => { + expect.assertions(4); + + const errorMessage = 'request failed'; + getAppInfoMock.mockResolvedValue({ error: { message: errorMessage }, status: 'failure' }); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + await expect( + // The async is needed so the act will finished before asserting for the callout + async () => await act(async () => await beforeActionConnectorSaveFn()) + ).rejects.toThrow(errorMessage); + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + + test('should migrate the deprecated connector when the application throws', async () => { + getAppInfoMock.mockResolvedValue(applicationInfoData); + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + expect(wrapper.find('[data-test-subj="update-connector-btn"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="update-connector-btn"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + await act(async () => { + // Update the connector + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(updateActionConnectorMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: usesTableApiConnector.id, + connector: { + name: usesTableApiConnector.name, + config: { ...usesTableApiConnector.config, isLegacy: false }, + secrets: usesTableApiConnector.secrets, + }, + }) + ); + + expect(services.notifications.toasts.addSuccess).toHaveBeenCalledWith({ + text: 'Connector has been updated.', + title: 'SN connector updated', + }); + + // The flyout is closed + wrapper.update(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeFalsy(); + }); + + test('should NOT migrate the deprecated connector when there is an error', async () => { + const errorMessage = 'request failed'; + getAppInfoMock.mockRejectedValueOnce(new Error(errorMessage)); + + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + setCallbacks={setCallbacks} + isEdit={false} + /> + ); + + expect(wrapper.find('[data-test-subj="update-connector-btn"]').exists()).toBeTruthy(); + wrapper.find('[data-test-subj="update-connector-btn"]').first().simulate('click'); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + // The async is needed so the act will finished before asserting for the callout + await act(async () => { + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + }); + + expect(getAppInfoMock).toHaveBeenCalledTimes(1); + expect(updateActionConnectorMock).not.toHaveBeenCalled(); + + expect(services.notifications.toasts.addSuccess).not.toHaveBeenCalled(); + + // The flyout is still open + wrapper.update(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + + // The error message should be shown to the user + expect( + wrapper + .find('[data-test-subj="updateConnectorForm"] [data-test-subj="snApplicationCallout"]') + .exists() + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="updateConnectorForm"] [data-test-subj="snApplicationCallout"]') + .first() + .text() + .includes(errorMessage) + ).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 2cf738c5e0c13..20d38cfc7cea8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -17,7 +17,7 @@ import { useGetAppInfo } from './use_get_app_info'; import { ApplicationRequiredCallout } from './application_required_callout'; import { isRESTApiError, isLegacyConnector } from './helpers'; import { InstallationCallout } from './installation_callout'; -import { UpdateConnectorModal } from './update_connector_modal'; +import { UpdateConnector } from './update_connector'; import { updateActionConnector } from '../../../lib/action_connector_api'; import { Credentials } from './credentials'; @@ -40,7 +40,7 @@ const ServiceNowConnectorFields: React.FC setShowModal(true), []); - const onModalCancel = useCallback(() => setShowModal(false), []); - - const onModalConfirm = useCallback(async () => { - await getApplicationInfo(); - await updateActionConnector({ - http, - connector: { - name: action.name, - config: { apiUrl, isLegacy: false }, - secrets: { username, password }, - }, - id: action.id, - }); - - editActionConfig('isLegacy', false); - setShowModal(false); - - toasts.addSuccess({ - title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name), - text: i18n.MIGRATION_SUCCESS_TOAST_TEXT, - }); + const onMigrateClick = useCallback(() => setShowUpdateConnector(true), []); + const onModalCancel = useCallback(() => setShowUpdateConnector(false), []); + + const onUpdateConnectorConfirm = useCallback(async () => { + try { + await getApplicationInfo(); + + await updateActionConnector({ + http, + connector: { + name: action.name, + config: { apiUrl, isLegacy: false }, + secrets: { username, password }, + }, + id: action.id, + }); + + editActionConfig('isLegacy', false); + setShowUpdateConnector(false); + + toasts.addSuccess({ + title: i18n.UPDATE_SUCCESS_TOAST_TITLE(action.name), + text: i18n.UPDATE_SUCCESS_TOAST_TEXT, + }); + } catch (err) { + /** + * getApplicationInfo may throw an error if the request + * fails or if there is a REST api error. + * + * We silent the errors as a callout will show and inform the user + */ + } }, [ getApplicationInfo, http, @@ -115,8 +125,8 @@ const ServiceNowConnectorFields: React.FC - {showModal && ( - )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index 30e09356e95dd..078b5535c16eb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { mountWithIntl } from '@kbn/test/jest'; import { act } from '@testing-library/react'; import { ActionConnector } from '../../../../types'; @@ -115,13 +115,15 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('all params fields is rendered', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_idInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_displayInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="commentsTextArea"]').exists()).toBeTruthy(); }); @@ -132,7 +134,7 @@ describe('ServiceNowITSMParamsFields renders', () => { // eslint-disable-next-line @typescript-eslint/naming-convention errors: { 'subActionParams.incident.short_description': ['error'] }, }; - const wrapper = mount(); + const wrapper = mountWithIntl(); const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); expect(title.prop('isInvalid')).toBeTruthy(); }); @@ -144,10 +146,9 @@ describe('ServiceNowITSMParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -161,18 +162,17 @@ describe('ServiceNowITSMParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual('pushToService'); }); test('Resets fields when connector changes', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(editAction.mock.calls.length).toEqual(0); wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -180,7 +180,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the categories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -195,7 +195,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the subcategories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -210,7 +210,7 @@ describe('ServiceNowITSMParamsFields renders', () => { }); test('it transforms the options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoices(useGetChoicesResponse.choices); }); @@ -231,6 +231,11 @@ describe('ServiceNowITSMParamsFields renders', () => { const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; const simpleFields = [ { dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' }, + { dataTestSubj: 'input[data-test-subj="correlation_idInput"]', key: 'correlation_id' }, + { + dataTestSubj: 'input[data-test-subj="correlation_displayInput"]', + key: 'correlation_display', + }, { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, { dataTestSubj: '[data-test-subj="urgencySelect"]', key: 'urgency' }, { dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' }, @@ -241,7 +246,7 @@ describe('ServiceNowITSMParamsFields renders', () => { simpleFields.forEach((field) => test(`${field.key} update triggers editAction :D`, () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const theField = wrapper.find(field.dataTestSubj).first(); theField.prop('onChange')!(changeEvent); expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); @@ -249,14 +254,14 @@ describe('ServiceNowITSMParamsFields renders', () => { ); test('A comment triggers editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); test('An empty comment does not trigger editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const emptyComment = { target: { value: '' } }; const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); expect(comments.simulate('change', emptyComment)); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx index 81428cd7f0a73..09b04f0fa3c48 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.tsx @@ -13,18 +13,19 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, - EuiSwitch, + EuiLink, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables'; import { useGetChoices } from './use_get_choices'; -import { choicesToEuiOptions, isLegacyConnector } from './helpers'; +import { choicesToEuiOptions, DEFAULT_CORRELATION_ID, isLegacyConnector } from './helpers'; import * as i18n from './translations'; -import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory']; const defaultFields: Fields = { @@ -40,11 +41,14 @@ const ServiceNowParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { const { + docLinks, http, notifications: { toasts }, } = useKibana().services; - const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const isDeprecatedConnector = isLegacyConnector( + actionConnector as unknown as ServiceNowActionConnector + ); const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( @@ -57,13 +61,8 @@ const ServiceNowParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const hasUpdateIncident = - incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; - const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); - const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; - const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -99,14 +98,6 @@ const ServiceNowParamsFields: React.FunctionComponent< ); }, []); - const onUpdateIncidentSwitchChange = useCallback(() => { - const newCorrelationID = !updateIncident - ? UPDATE_INCIDENT_VARIABLE - : NOT_UPDATE_INCIDENT_VARIABLE; - editSubActionProperty('correlation_id', newCorrelationID); - setUpdateIncident(!updateIncident); - }, [editSubActionProperty, updateIncident]); - const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]); const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]); @@ -136,7 +127,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -153,7 +144,7 @@ const ServiceNowParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -253,6 +244,46 @@ const ServiceNowParamsFields: React.FunctionComponent< + {!isDeprecatedConnector && ( + <> + + + + + + } + > + + + + + + + + + + + + )} - {!isOldConnector && ( - - - - - - )} { }); test('all params fields is rendered', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(wrapper.find('[data-test-subj="short_descriptionInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="source_ipInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="dest_ipInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="malware_urlInput"]').exists()).toBeTruthy(); - expect(wrapper.find('[data-test-subj="malware_hashInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_idInput"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="correlation_displayInput"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); @@ -162,7 +160,7 @@ describe('ServiceNowSIRParamsFields renders', () => { // eslint-disable-next-line @typescript-eslint/naming-convention errors: { 'subActionParams.incident.short_description': ['error'] }, }; - const wrapper = mount(); + const wrapper = mountWithIntl(); const title = wrapper.find('[data-test-subj="short_descriptionInput"]').first(); expect(title.prop('isInvalid')).toBeTruthy(); }); @@ -174,10 +172,9 @@ describe('ServiceNowSIRParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -191,18 +188,17 @@ describe('ServiceNowSIRParamsFields renders', () => { ...defaultProps, actionParams: newParams, }; - mount(); + mountWithIntl(); expect(editAction.mock.calls[0][1]).toEqual('pushToService'); }); test('Resets fields when connector changes', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); expect(editAction.mock.calls.length).toEqual(0); wrapper.setProps({ actionConnector: { ...connector, id: '1234' } }); expect(editAction.mock.calls.length).toEqual(1); expect(editAction.mock.calls[0][1]).toEqual({ incident: { - correlation_display: 'Alerting', correlation_id: '{{rule.id}}:{{alert.id}}', }, comments: [], @@ -210,7 +206,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the categories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -227,7 +223,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the subcategories to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -250,7 +246,7 @@ describe('ServiceNowSIRParamsFields renders', () => { }); test('it transforms the priorities to options correctly', async () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); act(() => { onChoicesSuccess(choicesResponse.choices); }); @@ -284,11 +280,12 @@ describe('ServiceNowSIRParamsFields renders', () => { const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent; const simpleFields = [ { dataTestSubj: 'input[data-test-subj="short_descriptionInput"]', key: 'short_description' }, + { dataTestSubj: 'input[data-test-subj="correlation_idInput"]', key: 'correlation_id' }, + { + dataTestSubj: 'input[data-test-subj="correlation_displayInput"]', + key: 'correlation_display', + }, { dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' }, - { dataTestSubj: '[data-test-subj="source_ipInput"]', key: 'source_ip' }, - { dataTestSubj: '[data-test-subj="dest_ipInput"]', key: 'dest_ip' }, - { dataTestSubj: '[data-test-subj="malware_urlInput"]', key: 'malware_url' }, - { dataTestSubj: '[data-test-subj="malware_hashInput"]', key: 'malware_hash' }, { dataTestSubj: '[data-test-subj="prioritySelect"]', key: 'priority' }, { dataTestSubj: '[data-test-subj="categorySelect"]', key: 'category' }, { dataTestSubj: '[data-test-subj="subcategorySelect"]', key: 'subcategory' }, @@ -296,7 +293,7 @@ describe('ServiceNowSIRParamsFields renders', () => { simpleFields.forEach((field) => test(`${field.key} update triggers editAction :D`, () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const theField = wrapper.find(field.dataTestSubj).first(); theField.prop('onChange')!(changeEvent); expect(editAction.mock.calls[0][1].incident[field.key]).toEqual(changeEvent.target.value); @@ -304,14 +301,14 @@ describe('ServiceNowSIRParamsFields renders', () => { ); test('A comment triggers editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const comments = wrapper.find('textarea[data-test-subj="commentsTextArea"]'); expect(comments.simulate('change', changeEvent)); expect(editAction.mock.calls[0][1].comments.length).toEqual(1); }); test('An empty comment does not trigger editAction', () => { - const wrapper = mount(); + const wrapper = mountWithIntl(); const emptyComment = { target: { value: '' } }; const comments = wrapper.find('[data-test-subj="commentsTextArea"] textarea'); expect(comments.simulate('change', emptyComment)); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx index 7b7cfc67d9971..72f6d7635268f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_sir_params.tsx @@ -13,8 +13,10 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, - EuiSwitch, + EuiLink, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { useKibana } from '../../../../common/lib/kibana'; import { ActionParamsProps } from '../../../../types'; import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables'; @@ -23,8 +25,7 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var import * as i18n from './translations'; import { useGetChoices } from './use_get_choices'; import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types'; -import { choicesToEuiOptions, isLegacyConnector } from './helpers'; -import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config'; +import { choicesToEuiOptions, isLegacyConnector, DEFAULT_CORRELATION_ID } from './helpers'; const useGetChoicesFields = ['category', 'subcategory', 'priority']; const defaultFields: Fields = { @@ -33,23 +34,18 @@ const defaultFields: Fields = { priority: [], }; -const valuesToString = (value: string | string[] | null): string | undefined => { - if (Array.isArray(value)) { - return value.join(','); - } - - return value ?? undefined; -}; - const ServiceNowSIRParamsFields: React.FunctionComponent< ActionParamsProps > = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => { const { + docLinks, http, notifications: { toasts }, } = useKibana().services; - const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector); + const isDeprecatedConnector = isLegacyConnector( + actionConnector as unknown as ServiceNowActionConnector + ); const actionConnectorRef = useRef(actionConnector?.id ?? ''); const { incident, comments } = useMemo( @@ -62,13 +58,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< [actionParams.subActionParams] ); - const hasUpdateIncident = - incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE; - const [updateIncident, setUpdateIncident] = useState(hasUpdateIncident); const [choices, setChoices] = useState(defaultFields); - const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE; - const editSubActionProperty = useCallback( (key: string, value: any) => { const newProps = @@ -104,14 +95,6 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< ); }, []); - const onUpdateIncidentSwitchChange = useCallback(() => { - const newCorrelationID = !updateIncident - ? UPDATE_INCIDENT_VARIABLE - : NOT_UPDATE_INCIDENT_VARIABLE; - editSubActionProperty('correlation_id', newCorrelationID); - setUpdateIncident(!updateIncident); - }, [editSubActionProperty, updateIncident]); - const { isLoading: isLoadingChoices } = useGetChoices({ http, toastNotifications: toasts, @@ -140,7 +123,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -157,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< editAction( 'subActionParams', { - incident: { correlation_id: correlationID, correlation_display: 'Alerting' }, + incident: { correlation_id: DEFAULT_CORRELATION_ID }, comments: [], }, index @@ -192,46 +175,6 @@ const ServiceNowSIRParamsFields: React.FunctionComponent< /> - - - - - - - - - - - - - - - - + {!isDeprecatedConnector && ( + <> + + + + + + } + > + + + + + + + + + + + + )} - {!isOldConnector && ( - - - - )} ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx index fe73653234170..500325202b651 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.test.tsx @@ -7,21 +7,43 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import { SNStoreButton } from './sn_store_button'; +import { SNStoreButton, SNStoreLink } from './sn_store_button'; describe('SNStoreButton', () => { - test('it renders the button', () => { + it('should render the button', () => { render(); expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); }); - test('it renders a danger button', () => { + it('should render a danger button', () => { render(); expect(screen.getByRole('link')).toHaveClass('euiButton--danger'); }); - test('it renders with correct href', () => { + it('should render with correct href', () => { render(); expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); }); + + it('should render with target blank', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); +}); + +describe('SNStoreLink', () => { + it('should render the link', () => { + render(); + expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument(); + }); + + it('should render with correct href', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/'); + }); + + it('should render with target blank', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('target', '_blank'); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx index 5921f679d3f50..5a33237159a02 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/sn_store_button.tsx @@ -6,7 +6,7 @@ */ import React, { memo } from 'react'; -import { EuiButtonProps, EuiButton } from '@elastic/eui'; +import { EuiButtonProps, EuiButton, EuiLink } from '@elastic/eui'; import * as i18n from './translations'; @@ -18,10 +18,18 @@ interface Props { const SNStoreButtonComponent: React.FC = ({ color }) => { return ( - + {i18n.VISIT_SN_STORE} ); }; export const SNStoreButton = memo(SNStoreButtonComponent); + +const SNStoreLinkComponent: React.FC = () => ( + + {i18n.VISIT_SN_STORE} + +); + +export const SNStoreLink = memo(SNStoreLinkComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 90292a35a88df..d068b120bd7ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -17,7 +17,7 @@ export const API_URL_LABEL = i18n.translate( export const API_URL_HELPTEXT = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText', { - defaultMessage: 'Include the full URL', + defaultMessage: 'Include the full URL.', } ); @@ -60,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate( export const REENTER_VALUES_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel', { - defaultMessage: 'You will need to re-authenticate each time you edit the connector', + defaultMessage: 'You must authenticate each time you edit the connector.', } ); @@ -99,34 +99,6 @@ export const TITLE_REQUIRED = i18n.translate( } ); -export const SOURCE_IP_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle', - { - defaultMessage: 'Source IPs', - } -); - -export const SOURCE_IP_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText', - { - defaultMessage: 'List of source IPs (comma, or pipe delimited)', - } -); - -export const DEST_IP_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle', - { - defaultMessage: 'Destination IPs', - } -); - -export const DEST_IP_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText', - { - defaultMessage: 'List of destination IPs (comma, or pipe delimited)', - } -); - export const INCIDENT = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.title', { @@ -155,34 +127,6 @@ export const COMMENTS_LABEL = i18n.translate( } ); -export const MALWARE_URL_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle', - { - defaultMessage: 'Malware URLs', - } -); - -export const MALWARE_URL_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText', - { - defaultMessage: 'List of malware URLs (comma, or pipe delimited)', - } -); - -export const MALWARE_HASH_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', - { - defaultMessage: 'Malware Hashes', - } -); - -export const MALWARE_HASH_HELP_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText', - { - defaultMessage: 'List of malware hashes (comma, or pipe delimited)', - } -); - export const CHOICES_API_ERROR = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage', { @@ -249,25 +193,25 @@ export const INSTALLATION_CALLOUT_TITLE = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle', { defaultMessage: - 'To use this connector, you must first install the Elastic App from the ServiceNow App Store', + 'To use this connector, first install the Elastic app from the ServiceNow app store.', } ); -export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) => +export const UPDATE_SUCCESS_TOAST_TITLE = (connectorName: string) => i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle', + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateSuccessToastTitle', { - defaultMessage: 'Migrated connector {connectorName}', + defaultMessage: '{connectorName} connector updated', values: { connectorName, }, } ); -export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText', +export const UPDATE_SUCCESS_TOAST_TEXT = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateCalloutText', { - defaultMessage: 'Connector has been successfully migrated.', + defaultMessage: 'Connector has been updated.', } ); @@ -299,23 +243,16 @@ export const UNKNOWN = i18n.translate( } ); -export const UPDATE_INCIDENT_LABEL = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel', - { - defaultMessage: 'Update incident', - } -); - -export const ON = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn', +export const CORRELATION_ID = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.correlationID', { - defaultMessage: 'On', + defaultMessage: 'Correlation ID (optional)', } ); -export const OFF = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff', +export const CORRELATION_DISPLAY = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.correlationDisplay', { - defaultMessage: 'Off', + defaultMessage: 'Correlation display (optional)', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx new file mode 100644 index 0000000000000..2d95bfa85ceb9 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { mountWithIntl } from '@kbn/test/jest'; +import { UpdateConnector, Props } from './update_connector'; +import { ServiceNowActionConnector } from './types'; +jest.mock('../../../../common/lib/kibana'); + +const actionConnector: ServiceNowActionConnector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + isPreconfigured: false, + name: 'servicenow', + config: { + apiUrl: 'https://test/', + isLegacy: true, + }, +}; + +const mountUpdateConnector = (props: Partial = {}) => { + return mountWithIntl( + {}} + editActionSecrets={() => {}} + readOnly={false} + isLoading={false} + onConfirm={() => {}} + onCancel={() => {}} + {...props} + /> + ); +}; + +describe('UpdateConnector renders', () => { + it('should render update connector fields', () => { + const wrapper = mountUpdateConnector(); + + expect(wrapper.find('[data-test-subj="snUpdateInstallationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="updateConnectorForm"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').exists()).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-servicenow-username-form-input"]').exists() + ).toBeTruthy(); + expect( + wrapper.find('[data-test-subj="connector-servicenow-password-form-input"]').exists() + ).toBeTruthy(); + }); + + it('should disable inputs on loading', () => { + const wrapper = mountUpdateConnector({ isLoading: true }); + expect( + wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').first().prop('disabled') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-username-form-input"]') + .first() + .prop('disabled') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-password-form-input"]') + .first() + .prop('disabled') + ).toBeTruthy(); + }); + + it('should set inputs as read-only', () => { + const wrapper = mountUpdateConnector({ readOnly: true }); + + expect( + wrapper.find('[data-test-subj="credentialsApiUrlFromInput"]').first().prop('readOnly') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-username-form-input"]') + .first() + .prop('readOnly') + ).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="connector-servicenow-password-form-input"]') + .first() + .prop('readOnly') + ).toBeTruthy(); + }); + + it('should disable submit button if errors or fields missing', () => { + const wrapper = mountUpdateConnector({ + errors: { apiUrl: ['some error'], username: [], password: [] }, + }); + + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeTruthy(); + + wrapper.setProps({ ...wrapper.props(), errors: { apiUrl: [], username: [], password: [] } }); + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeFalsy(); + + wrapper.setProps({ + ...wrapper.props(), + action: { ...actionConnector, secrets: { ...actionConnector.secrets, username: undefined } }, + }); + expect( + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().prop('disabled') + ).toBeTruthy(); + }); + + it('should call editActionConfig when editing api url', () => { + const editActionConfig = jest.fn(); + const wrapper = mountUpdateConnector({ editActionConfig }); + + expect(editActionConfig).not.toHaveBeenCalled(); + wrapper + .find('input[data-test-subj="credentialsApiUrlFromInput"]') + .simulate('change', { target: { value: 'newUrl' } }); + expect(editActionConfig).toHaveBeenCalledWith('apiUrl', 'newUrl'); + }); + + it('should call editActionSecrets when editing username or password', () => { + const editActionSecrets = jest.fn(); + const wrapper = mountUpdateConnector({ editActionSecrets }); + + expect(editActionSecrets).not.toHaveBeenCalled(); + wrapper + .find('input[data-test-subj="connector-servicenow-username-form-input"]') + .simulate('change', { target: { value: 'new username' } }); + expect(editActionSecrets).toHaveBeenCalledWith('username', 'new username'); + + wrapper + .find('input[data-test-subj="connector-servicenow-password-form-input"]') + .simulate('change', { target: { value: 'new pass' } }); + + expect(editActionSecrets).toHaveBeenCalledTimes(2); + expect(editActionSecrets).toHaveBeenLastCalledWith('password', 'new pass'); + }); + + it('should confirm the update when submit button clicked', () => { + const onConfirm = jest.fn(); + const wrapper = mountUpdateConnector({ onConfirm }); + + expect(onConfirm).not.toHaveBeenCalled(); + wrapper.find('[data-test-subj="snUpdateInstallationSubmit"]').first().simulate('click'); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('should cancel the update when cancel button clicked', () => { + const onCancel = jest.fn(); + const wrapper = mountUpdateConnector({ onCancel }); + + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('[data-test-subj="snUpdateInstallationCancel"]').first().simulate('click'); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should show error message if present', () => { + const applicationInfoErrorMsg = 'some application error'; + const wrapper = mountUpdateConnector({ + applicationInfoErrorMsg, + }); + + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="snApplicationCallout"]').first().text()).toContain( + applicationInfoErrorMsg + ); + }); +}); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx new file mode 100644 index 0000000000000..02127eb6ff4f0 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector.tsx @@ -0,0 +1,208 @@ +/* + * 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, { memo } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiCallOut, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiSteps, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionConnectorFieldsProps } from '../../../../../public/types'; +import { ServiceNowActionConnector } from './types'; +import { CredentialsApiUrl } from './credentials_api_url'; +import { isFieldInvalid } from './helpers'; +import { ApplicationRequiredCallout } from './application_required_callout'; +import { SNStoreLink } from './sn_store_button'; +import { CredentialsAuth } from './credentials_auth'; + +const title = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormTitle', + { + defaultMessage: 'Update ServiceNow connector', + } +); + +const step1InstallTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormInstallTitle', + { + defaultMessage: 'Install the Elastic ServiceNow app', + } +); + +const step2InstanceUrlTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormUrlTitle', + { + defaultMessage: 'Enter your ServiceNow instance URL', + } +); + +const step3CredentialsTitle = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.updateFormCredentialsTitle', + { + defaultMessage: 'Provide authentication credentials', + } +); + +const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', + { + defaultMessage: 'Cancel', + } +); + +const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', + { + defaultMessage: 'Update', + } +); + +const warningMessage = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.warningMessage', + { + defaultMessage: 'This updates all instances of this connector and cannot be reversed.', + } +); + +export interface Props { + action: ActionConnectorFieldsProps['action']; + applicationInfoErrorMsg: string | null; + errors: ActionConnectorFieldsProps['errors']; + isLoading: boolean; + readOnly: boolean; + editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; + editActionConfig: ActionConnectorFieldsProps['editActionConfig']; + onCancel: () => void; + onConfirm: () => void; +} + +const UpdateConnectorComponent: React.FC = ({ + action, + applicationInfoErrorMsg, + errors, + isLoading, + readOnly, + editActionSecrets, + editActionConfig, + onCancel, + onConfirm, +}) => { + const { apiUrl } = action.config; + const { username, password } = action.secrets; + + const hasErrorsOrEmptyFields = + apiUrl === undefined || + username === undefined || + password === undefined || + isFieldInvalid(apiUrl, errors.apiUrl) || + isFieldInvalid(username, errors.username) || + isFieldInvalid(password, errors.password); + + return ( + + + +

{title}

+
+
+ + } + > + + , + }} + /> + ), + }, + { + title: step2InstanceUrlTitle, + children: ( + + ), + }, + { + title: step3CredentialsTitle, + children: ( + + ), + }, + ]} + /> + + + + {applicationInfoErrorMsg && ( + + )} + + + + + + + + {cancelButtonText} + + + + + {confirmButtonText} + + + + +
+ ); +}; + +export const UpdateConnector = memo(UpdateConnectorComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx deleted file mode 100644 index b9d660f16dff7..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/update_connector_modal.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo } from 'react'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiModal, - EuiModalBody, - EuiModalFooter, - EuiModalHeader, - EuiModalHeaderTitle, - EuiCallOut, - EuiTextColor, - EuiHorizontalRule, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { ActionConnectorFieldsProps } from '../../../../../public/types'; -import { ServiceNowActionConnector } from './types'; -import { Credentials } from './credentials'; -import { isFieldInvalid } from './helpers'; -import { ApplicationRequiredCallout } from './application_required_callout'; - -const title = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle', - { - defaultMessage: 'Update ServiceNow connector', - } -); - -const cancelButtonText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText', - { - defaultMessage: 'Cancel', - } -); - -const confirmButtonText = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText', - { - defaultMessage: 'Update', - } -); - -const calloutTitle = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle', - { - defaultMessage: - 'The Elastic App from the ServiceNow App Store must be installed prior to running the update.', - } -); - -const warningMessage = i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage', - { - defaultMessage: 'This will update all instances of this connector. This can not be reversed.', - } -); - -interface Props { - action: ActionConnectorFieldsProps['action']; - applicationInfoErrorMsg: string | null; - errors: ActionConnectorFieldsProps['errors']; - isLoading: boolean; - readOnly: boolean; - editActionSecrets: ActionConnectorFieldsProps['editActionSecrets']; - editActionConfig: ActionConnectorFieldsProps['editActionConfig']; - onCancel: () => void; - onConfirm: () => void; -} - -const UpdateConnectorModalComponent: React.FC = ({ - action, - applicationInfoErrorMsg, - errors, - isLoading, - readOnly, - editActionSecrets, - editActionConfig, - onCancel, - onConfirm, -}) => { - const { apiUrl } = action.config; - const { username, password } = action.secrets; - - const hasErrorsOrEmptyFields = - apiUrl === undefined || - username === undefined || - password === undefined || - isFieldInvalid(apiUrl, errors.apiUrl) || - isFieldInvalid(username, errors.username) || - isFieldInvalid(password, errors.password); - - return ( - - - -

{title}

-
-
- - - - - - - - - - - {warningMessage} - - - - - {applicationInfoErrorMsg && ( - - )} - - - - - {cancelButtonText} - - {confirmButtonText} - - -
- ); -}; - -export const UpdateConnectorModal = memo(UpdateConnectorModalComponent); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 04f2334f8e8fa..844f28f022547 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { ClassNames } from '@emotion/react'; import React, { useState, useEffect } from 'react'; import { EuiInMemoryTable, @@ -24,6 +25,7 @@ import { import { i18n } from '@kbn/i18n'; import { omit } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; +import { withTheme, EuiTheme } from '../../../../../../../../src/plugins/kibana_react/common'; import { loadAllActions, loadActionTypes, deleteActions } from '../../../lib/action_connector_api'; import { hasDeleteActionsCapability, @@ -52,6 +54,33 @@ import { // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../../actions/server/constants/connectors'; +const ConnectorIconTipWithSpacing = withTheme(({ theme }: { theme: EuiTheme }) => { + return ( + + {({ css }) => ( + + )} + + ); +}); + const ActionsConnectorsList: React.FunctionComponent = () => { const { http, @@ -204,23 +233,7 @@ const ActionsConnectorsList: React.FunctionComponent = () => { position="right" /> ) : null} - {showLegacyTooltip && ( - - )} + {showLegacyTooltip && } ); From d93b21374718d88d564c65da4f007978e2acdca7 Mon Sep 17 00:00:00 2001 From: DeDe Morton Date: Mon, 18 Oct 2021 14:10:27 -0700 Subject: [PATCH 25/54] Update doc links to Fleet/Agent docs (#115289) (#115455) --- docs/getting-started/quick-start-guide.asciidoc | 2 +- docs/osquery/osquery.asciidoc | 2 +- docs/setup/connect-to-elasticsearch.asciidoc | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/getting-started/quick-start-guide.asciidoc b/docs/getting-started/quick-start-guide.asciidoc index d614aece5b425..2bddd9bf61452 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 1e4e6604a7c70..a4f3c80463143 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 7dcc803d3e18b..6988460efadcf 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]] From 357523adae05f5066a3c8b2fb603253ba2393371 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 17:20:42 -0400 Subject: [PATCH 26/54] [APM] Adding error rate tests (#115190) (#115432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cauê Marcondes <55978943+cauemarcondes@users.noreply.github.com> --- .../tests/error_rate/service_apis.ts | 213 ++++++++++++++++++ .../test/apm_api_integration/tests/index.ts | 4 + 2 files changed, 217 insertions(+) create mode 100644 x-pack/test/apm_api_integration/tests/error_rate/service_apis.ts 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 0000000000000..75ea10ed4d9d4 --- /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 c15a7d39a6cf6..09f4e2596ea46 100644 --- a/x-pack/test/apm_api_integration/tests/index.ts +++ b/x-pack/test/apm_api_integration/tests/index.ts @@ -229,6 +229,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')); }); From e49f44fd2481ad6ee3d0eefb55d5d41a8e7cf83d Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 17:30:34 -0400 Subject: [PATCH 27/54] Document edge cases for enterpriseSearch.host (#115446) (#115457) Fixes https://github.com/elastic/enterprise-search-team/issues/517 Co-authored-by: Rich Kuzsma <62522248+richkuz@users.noreply.github.com> --- docs/setup/settings.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 2f4fd4d052dad..6527efd6e38d1 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -291,7 +291,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`* From 21f3eb72d733618d5d3529767455aca9d68c8153 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 18 Oct 2021 14:58:54 -0700 Subject: [PATCH 28/54] [7.x] [Alerting] Active alerts do not recover after re-enabling a rule (#111671) (#115443) * fixed merge * fixed merge --- ...eate_alert_event_log_record_object.test.ts | 210 ++++++++++++++++++ .../create_alert_event_log_record_object.ts | 81 +++++++ x-pack/plugins/alerting/server/plugin.ts | 1 + .../server/rules_client/rules_client.ts | 60 ++++- .../server/rules_client/tests/disable.test.ts | 133 +++++++++++ .../rules_client_conflict_retries.test.ts | 16 ++ .../alerting/server/rules_client_factory.ts | 6 +- .../create_execution_handler.test.ts | 1 - .../task_runner/create_execution_handler.ts | 67 +++--- .../server/task_runner/task_runner.test.ts | 3 - .../server/task_runner/task_runner.ts | 55 ++--- .../spaces_only/tests/alerting/disable.ts | 73 ++++++ 12 files changed, 629 insertions(+), 77 deletions(-) create mode 100644 x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts create mode 100644 x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts new file mode 100644 index 0000000000000..0731886bcaeb0 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.test.ts @@ -0,0 +1,210 @@ +/* + * 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 { createAlertEventLogRecordObject } from './create_alert_event_log_record_object'; +import { UntypedNormalizedAlertType } from '../rule_type_registry'; +import { RecoveredActionGroup } from '../types'; + +describe('createAlertEventLogRecordObject', () => { + const ruleType: jest.Mocked = { + id: 'test', + name: 'My test alert', + actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: RecoveredActionGroup, + executor: jest.fn(), + producer: 'alerts', + }; + + test('created alert event "execute-start"', async () => { + expect( + createAlertEventLogRecordObject({ + ruleId: '1', + ruleType, + action: 'execute-start', + timestamp: '1970-01-01T00:00:00.000Z', + task: { + scheduled: '1970-01-01T00:00:00.000Z', + scheduleDelay: 0, + }, + savedObjects: [ + { + id: '1', + type: 'alert', + typeId: ruleType.id, + relation: 'primary', + }, + ], + }) + ).toStrictEqual({ + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { + action: 'execute-start', + category: ['alerts'], + kind: 'alert', + }, + kibana: { + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + ], + task: { + schedule_delay: 0, + scheduled: '1970-01-01T00:00:00.000Z', + }, + }, + rule: { + category: 'test', + id: '1', + license: 'basic', + ruleset: 'alerts', + }, + }); + }); + + test('created alert event "recovered-instance"', async () => { + expect( + createAlertEventLogRecordObject({ + ruleId: '1', + ruleName: 'test name', + ruleType, + action: 'recovered-instance', + instanceId: 'test1', + group: 'group 1', + message: 'message text here', + namespace: 'default', + subgroup: 'subgroup value', + state: { + start: '1970-01-01T00:00:00.000Z', + end: '1970-01-01T00:05:00.000Z', + duration: 5, + }, + savedObjects: [ + { + id: '1', + type: 'alert', + typeId: ruleType.id, + relation: 'primary', + }, + ], + }) + ).toStrictEqual({ + event: { + action: 'recovered-instance', + category: ['alerts'], + duration: 5, + end: '1970-01-01T00:05:00.000Z', + kind: 'alert', + start: '1970-01-01T00:00:00.000Z', + }, + kibana: { + alerting: { + action_group_id: 'group 1', + action_subgroup: 'subgroup value', + instance_id: 'test1', + }, + saved_objects: [ + { + id: '1', + namespace: 'default', + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + ], + }, + message: 'message text here', + rule: { + category: 'test', + id: '1', + license: 'basic', + ruleset: 'alerts', + name: 'test name', + }, + }); + }); + + test('created alert event "execute-action"', async () => { + expect( + createAlertEventLogRecordObject({ + ruleId: '1', + ruleName: 'test name', + ruleType, + action: 'execute-action', + instanceId: 'test1', + group: 'group 1', + message: 'action execution start', + namespace: 'default', + subgroup: 'subgroup value', + state: { + start: '1970-01-01T00:00:00.000Z', + end: '1970-01-01T00:05:00.000Z', + duration: 5, + }, + savedObjects: [ + { + id: '1', + type: 'alert', + typeId: ruleType.id, + relation: 'primary', + }, + { + id: '2', + type: 'action', + typeId: '.email', + }, + ], + }) + ).toStrictEqual({ + event: { + action: 'execute-action', + category: ['alerts'], + duration: 5, + end: '1970-01-01T00:05:00.000Z', + kind: 'alert', + start: '1970-01-01T00:00:00.000Z', + }, + kibana: { + alerting: { + action_group_id: 'group 1', + action_subgroup: 'subgroup value', + instance_id: 'test1', + }, + saved_objects: [ + { + id: '1', + namespace: 'default', + rel: 'primary', + type: 'alert', + type_id: 'test', + }, + { + id: '2', + namespace: 'default', + type: 'action', + type_id: '.email', + }, + ], + }, + message: 'action execution start', + rule: { + category: 'test', + id: '1', + license: 'basic', + ruleset: 'alerts', + name: 'test name', + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts new file mode 100644 index 0000000000000..12300211cb0bb --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts @@ -0,0 +1,81 @@ +/* + * 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 { AlertInstanceState } from '../types'; +import { IEvent } from '../../../event_log/server'; +import { UntypedNormalizedAlertType } from '../rule_type_registry'; + +export type Event = Exclude; + +interface CreateAlertEventLogRecordParams { + ruleId: string; + ruleType: UntypedNormalizedAlertType; + action: string; + ruleName?: string; + instanceId?: string; + message?: string; + state?: AlertInstanceState; + group?: string; + subgroup?: string; + namespace?: string; + timestamp?: string; + task?: { + scheduled?: string; + scheduleDelay?: number; + }; + savedObjects: Array<{ + type: string; + id: string; + typeId: string; + relation?: string; + }>; +} + +export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecordParams): Event { + const { ruleType, action, state, message, task, ruleId, group, subgroup, namespace } = params; + const alerting = + params.instanceId || group || subgroup + ? { + alerting: { + ...(params.instanceId ? { instance_id: params.instanceId } : {}), + ...(group ? { action_group_id: group } : {}), + ...(subgroup ? { action_subgroup: subgroup } : {}), + }, + } + : undefined; + const event: Event = { + ...(params.timestamp ? { '@timestamp': params.timestamp } : {}), + event: { + action, + kind: 'alert', + category: [ruleType.producer], + ...(state?.start ? { start: state.start as string } : {}), + ...(state?.end ? { end: state.end as string } : {}), + ...(state?.duration !== undefined ? { duration: state.duration as number } : {}), + }, + kibana: { + ...(alerting ? alerting : {}), + saved_objects: params.savedObjects.map((so) => ({ + ...(so.relation ? { rel: so.relation } : {}), + type: so.type, + id: so.id, + type_id: so.typeId, + namespace, + })), + ...(task ? { task: { scheduled: task.scheduled, schedule_delay: task.scheduleDelay } } : {}), + }, + ...(message ? { message } : {}), + rule: { + id: ruleId, + license: ruleType.minimumLicenseRequired, + category: ruleType.id, + ruleset: ruleType.producer, + ...(params.ruleName ? { name: params.ruleName } : {}), + }, + }; + return event; +} diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 231787a889697..0e94991ab7b64 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -374,6 +374,7 @@ export class AlertingPlugin { eventLog: plugins.eventLog, kibanaVersion: this.kibanaVersion, authorization: alertingAuthorizationClientFactory, + eventLogger: this.eventLogger, }); const getRulesClientWithRequest = (request: KibanaRequest) => { diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 79c2a28c9419e..9044cdd0b6b01 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -7,7 +7,7 @@ import Semver from 'semver'; import Boom from '@hapi/boom'; -import { omit, isEqual, map, uniq, pick, truncate, trim } from 'lodash'; +import { omit, isEqual, map, uniq, pick, truncate, trim, mapValues } from 'lodash'; import { i18n } from '@kbn/i18n'; import { estypes } from '@elastic/elasticsearch'; import { @@ -35,6 +35,7 @@ import { AlertNotifyWhenType, AlertTypeParams, ResolvedSanitizedRule, + RawAlertInstance, } from '../types'; import { validateAlertTypeParams, @@ -57,10 +58,14 @@ import { AlertingAuthorizationFilterType, AlertingAuthorizationFilterOpts, } from '../authorization'; -import { IEventLogClient } from '../../../event_log/server'; +import { + IEvent, + IEventLogClient, + IEventLogger, + SAVED_OBJECT_REL_PRIMARY, +} from '../../../event_log/server'; import { parseIsoOrRelativeDate } from '../lib/iso_or_relative_date'; import { alertInstanceSummaryFromEventLog } from '../lib/alert_instance_summary_from_event_log'; -import { IEvent } from '../../../event_log/server'; import { AuditLogger } from '../../../security/server'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; @@ -70,6 +75,9 @@ import { ruleAuditEvent, RuleAuditAction } from './audit_events'; import { KueryNode, nodeBuilder } from '../../../../../src/plugins/data/common'; import { mapSortField } from './lib'; import { getAlertExecutionStatusPending } from '../lib/alert_execution_status'; +import { AlertInstance } from '../alert_instance'; +import { EVENT_LOG_ACTIONS } from '../plugin'; +import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; export interface RegistryAlertTypeWithAuth extends RegistryRuleType { authorizedConsumers: string[]; @@ -98,6 +106,7 @@ export interface ConstructorOptions { getEventLogClient: () => Promise; kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; auditLogger?: AuditLogger; + eventLogger?: IEventLogger; } export interface MuteOptions extends IndexType { @@ -212,6 +221,7 @@ export class RulesClient { private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private readonly auditLogger?: AuditLogger; + private readonly eventLogger?: IEventLogger; constructor({ ruleTypeRegistry, @@ -229,6 +239,7 @@ export class RulesClient { getEventLogClient, kibanaVersion, auditLogger, + eventLogger, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -245,6 +256,7 @@ export class RulesClient { this.getEventLogClient = getEventLogClient; this.kibanaVersion = kibanaVersion; this.auditLogger = auditLogger; + this.eventLogger = eventLogger; } public async create({ @@ -1179,6 +1191,48 @@ export class RulesClient { version = alert.version; } + if (this.eventLogger && attributes.scheduledTaskId) { + const { state } = taskInstanceToAlertTaskInstance( + await this.taskManager.get(attributes.scheduledTaskId), + attributes as unknown as SanitizedAlert + ); + + const recoveredAlertInstances = mapValues, AlertInstance>( + state.alertInstances ?? {}, + (rawAlertInstance) => new AlertInstance(rawAlertInstance) + ); + const recoveredAlertInstanceIds = Object.keys(recoveredAlertInstances); + + for (const instanceId of recoveredAlertInstanceIds) { + const { group: actionGroup, subgroup: actionSubgroup } = + recoveredAlertInstances[instanceId].getLastScheduledActions() ?? {}; + const instanceState = recoveredAlertInstances[instanceId].getState(); + const message = `instance '${instanceId}' has recovered due to the rule was disabled`; + + const event = createAlertEventLogRecordObject({ + ruleId: id, + ruleName: attributes.name, + ruleType: this.ruleTypeRegistry.get(attributes.alertTypeId), + instanceId, + action: EVENT_LOG_ACTIONS.recoveredInstance, + message, + state: instanceState, + group: actionGroup, + subgroup: actionSubgroup, + namespace: this.namespace, + savedObjects: [ + { + id, + type: 'alert', + typeId: attributes.alertTypeId, + relation: SAVED_OBJECT_REL_PRIMARY, + }, + ], + }); + this.eventLogger.logEvent(event); + } + } + try { await this.authorization.ensureAuthorized({ ruleTypeId: attributes.alertTypeId, diff --git a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts index 6b9b2021db68b..c518d385dd747 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/disable.test.ts @@ -18,6 +18,8 @@ import { InvalidatePendingApiKey } from '../../types'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; +import { eventLoggerMock } from '../../../../event_log/server/event_logger.mock'; +import { TaskStatus } from '../../../../task_manager/server'; const taskManager = taskManagerMock.createStart(); const ruleTypeRegistry = ruleTypeRegistryMock.create(); @@ -26,6 +28,7 @@ const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertingAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); +const eventLogger = eventLoggerMock.create(); const kibanaVersion = 'v7.10.0'; const rulesClientParams: jest.Mocked = { @@ -44,10 +47,26 @@ const rulesClientParams: jest.Mocked = { getEventLogClient: jest.fn(), kibanaVersion, auditLogger, + eventLogger, }; beforeEach(() => { getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); + taskManager.get.mockResolvedValue({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: { + alertId: '1', + }, + ownerId: null, + }); (auditLogger.log as jest.Mock).mockClear(); }); @@ -217,6 +236,120 @@ describe('disable()', () => { ).toBe('123'); }); + test('disables the rule with calling event log to "recover" the alert instances from the task state', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'api_key_pending_invalidation', + attributes: { + apiKeyId: '123', + createdAt: '2019-02-12T21:01:22.479Z', + }, + references: [], + }); + const scheduledTaskId = 'task-123'; + taskManager.get.mockResolvedValue({ + id: scheduledTaskId, + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: { + alertInstances: { + '1': { + meta: { + lastScheduledActions: { + group: 'default', + subgroup: 'newSubgroup', + date: new Date().toISOString(), + }, + }, + state: { bar: false }, + }, + }, + }, + params: { + alertId: '1', + }, + ownerId: null, + }); + await rulesClient.disable({ id: '1' }); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { + namespace: 'default', + }); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( + 'alert', + '1', + { + consumer: 'myApp', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + enabled: false, + meta: { + versionApiKeyLastmodified: kibanaVersion, + }, + scheduledTaskId: null, + apiKey: null, + apiKeyOwner: null, + updatedAt: '2019-02-12T21:01:22.479Z', + updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + }, + { + version: '123', + } + ); + expect(taskManager.removeIfExists).toHaveBeenCalledWith('task-123'); + expect( + (unsecuredSavedObjectsClient.create.mock.calls[0][1] as InvalidatePendingApiKey).apiKeyId + ).toBe('123'); + + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls[0][0]).toStrictEqual({ + event: { + action: 'recovered-instance', + category: ['alerts'], + kind: 'alert', + }, + kibana: { + alerting: { + action_group_id: 'default', + action_subgroup: 'newSubgroup', + instance_id: '1', + }, + saved_objects: [ + { + id: '1', + namespace: 'default', + rel: 'primary', + type: 'alert', + type_id: 'myType', + }, + ], + }, + message: "instance '1' has recovered due to the rule was disabled", + rule: { + category: '123', + id: '1', + license: 'basic', + ruleset: 'alerts', + }, + }); + }); + test('falls back when getDecryptedAsInternalUser throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts index dfc55efad41c6..0b35f250ba3c6 100644 --- a/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts +++ b/x-pack/plugins/alerting/server/rules_client_conflict_retries.test.ts @@ -325,6 +325,22 @@ beforeEach(() => { params: {}, }); + taskManager.get.mockResolvedValue({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: { + alertId: '1', + }, + ownerId: null, + }); + const actionsClient = actionsClientMock.create(); actionsClient.getBulk.mockResolvedValue([]); rulesClientParams.getActionsClient.mockResolvedValue(actionsClient); diff --git a/x-pack/plugins/alerting/server/rules_client_factory.ts b/x-pack/plugins/alerting/server/rules_client_factory.ts index 7961d3761d3ef..1e9a021a0be51 100644 --- a/x-pack/plugins/alerting/server/rules_client_factory.ts +++ b/x-pack/plugins/alerting/server/rules_client_factory.ts @@ -17,7 +17,7 @@ import { RuleTypeRegistry, SpaceIdToNamespaceFunction } from './types'; import { SecurityPluginSetup, SecurityPluginStart } from '../../security/server'; import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../task_manager/server'; -import { IEventLogClientService } from '../../../plugins/event_log/server'; +import { IEventLogClientService, IEventLogger } from '../../../plugins/event_log/server'; import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory'; export interface RulesClientFactoryOpts { logger: Logger; @@ -32,6 +32,7 @@ export interface RulesClientFactoryOpts { eventLog: IEventLogClientService; kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; authorization: AlertingAuthorizationClientFactory; + eventLogger?: IEventLogger; } export class RulesClientFactory { @@ -48,6 +49,7 @@ export class RulesClientFactory { private eventLog!: IEventLogClientService; private kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; private authorization!: AlertingAuthorizationClientFactory; + private eventLogger?: IEventLogger; public initialize(options: RulesClientFactoryOpts) { if (this.isInitialized) { @@ -66,6 +68,7 @@ export class RulesClientFactory { this.eventLog = options.eventLog; this.kibanaVersion = options.kibanaVersion; this.authorization = options.authorization; + this.eventLogger = options.eventLogger; } public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): RulesClient { @@ -123,6 +126,7 @@ export class RulesClientFactory { async getEventLogClient() { return eventLog.getClient(request); }, + eventLogger: this.eventLogger, }); } } diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts index e3946599aed85..244dcb85b13e9 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts @@ -175,7 +175,6 @@ test('enqueues execution per selected action', async () => { "kibana": Object { "alerting": Object { "action_group_id": "default", - "action_subgroup": undefined, "instance_id": "2", }, "saved_objects": Array [ diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts index 51301a80b1664..652e032a1cbb0 100644 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts @@ -10,7 +10,7 @@ import { asSavedObjectExecutionSource, PluginStartContract as ActionsPluginStartContract, } from '../../../actions/server'; -import { IEventLogger, IEvent, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; +import { IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { injectActionParams } from './inject_action_params'; import { @@ -21,8 +21,9 @@ import { AlertInstanceContext, RawAlert, } from '../types'; -import { NormalizedAlertType } from '../rule_type_registry'; +import { NormalizedAlertType, UntypedNormalizedAlertType } from '../rule_type_registry'; import { isEphemeralTaskRejectedDueToCapacityError } from '../../../task_manager/server'; +import { createAlertEventLogRecordObject } from '../lib/create_alert_event_log_record_object'; export interface CreateExecutionHandlerOptions< Params extends AlertTypeParams, @@ -201,43 +202,35 @@ export function createExecutionHandler< await actionsClient.enqueueExecution(enqueueOptions); } - const event: IEvent = { - event: { - action: EVENT_LOG_ACTIONS.executeAction, - kind: 'alert', - category: [alertType.producer], - }, - kibana: { - alerting: { - instance_id: alertInstanceId, - action_group_id: actionGroup, - action_subgroup: actionSubgroup, + const event = createAlertEventLogRecordObject({ + ruleId: alertId, + ruleType: alertType as UntypedNormalizedAlertType, + action: EVENT_LOG_ACTIONS.executeAction, + instanceId: alertInstanceId, + group: actionGroup, + subgroup: actionSubgroup, + ruleName: alertName, + savedObjects: [ + { + type: 'alert', + id: alertId, + typeId: alertType.id, + relation: SAVED_OBJECT_REL_PRIMARY, }, - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: alertId, - type_id: alertType.id, - ...namespace, - }, - { type: 'action', id: action.id, type_id: action.actionTypeId, ...namespace }, - ], - }, - rule: { - id: alertId, - license: alertType.minimumLicenseRequired, - category: alertType.id, - ruleset: alertType.producer, - name: alertName, - }, - }; + { + type: 'action', + id: action.id, + typeId: action.actionTypeId, + }, + ], + ...namespace, + message: `alert: ${alertLabel} instanceId: '${alertInstanceId}' scheduled ${ + actionSubgroup + ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` + : `actionGroup: '${actionGroup}'` + } action: ${actionLabel}`, + }); - event.message = `alert: ${alertLabel} instanceId: '${alertInstanceId}' scheduled ${ - actionSubgroup - ? `actionGroup(subgroup): '${actionGroup}(${actionSubgroup})'` - : `actionGroup: '${actionGroup}'` - } action: ${actionLabel}`; eventLogger.logEvent(event); } }; diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index c5ccc909eff46..07c4d0371c718 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -1379,7 +1379,6 @@ describe('Task Runner', () => { "kibana": Object { "alerting": Object { "action_group_id": "default", - "action_subgroup": undefined, "instance_id": "1", }, "saved_objects": Array [ @@ -1676,7 +1675,6 @@ describe('Task Runner', () => { "kibana": Object { "alerting": Object { "action_group_id": "recovered", - "action_subgroup": undefined, "instance_id": "2", }, "saved_objects": Array [ @@ -1717,7 +1715,6 @@ describe('Task Runner', () => { "kibana": Object { "alerting": Object { "action_group_id": "default", - "action_subgroup": undefined, "instance_id": "1", }, "saved_objects": Array [ diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index edf9bfe1b4846..8b93d3fa17211 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -49,16 +49,18 @@ import { AlertInstanceContext, WithoutReservedActionGroups, } from '../../common'; -import { NormalizedAlertType } from '../rule_type_registry'; +import { NormalizedAlertType, UntypedNormalizedAlertType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; +import { + createAlertEventLogRecordObject, + Event, +} from '../lib/create_alert_event_log_record_object'; const FALLBACK_RETRY_INTERVAL = '5m'; // 1,000,000 nanoseconds in 1 millisecond const Millis2Nanos = 1000 * 1000; -type Event = Exclude; - interface AlertTaskRunResult { state: AlertTaskState; schedule: IntervalSchedule | undefined; @@ -517,37 +519,26 @@ export class TaskRunner< const namespace = this.context.spaceIdToNamespace(spaceId); const eventLogger = this.context.eventLogger; const scheduleDelay = runDate.getTime() - this.taskInstance.runAt.getTime(); - const event: IEvent = { - // explicitly set execute timestamp so it will be before other events - // generated here (new-instance, schedule-action, etc) - '@timestamp': runDateString, - event: { - action: EVENT_LOG_ACTIONS.execute, - kind: 'alert', - category: [this.alertType.producer], + + const event = createAlertEventLogRecordObject({ + timestamp: runDateString, + ruleId: alertId, + ruleType: this.alertType as UntypedNormalizedAlertType, + action: EVENT_LOG_ACTIONS.execute, + namespace, + task: { + scheduled: this.taskInstance.runAt.toISOString(), + scheduleDelay: Millis2Nanos * scheduleDelay, }, - kibana: { - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: alertId, - type_id: this.alertType.id, - namespace, - }, - ], - task: { - scheduled: this.taskInstance.runAt.toISOString(), - schedule_delay: Millis2Nanos * scheduleDelay, + savedObjects: [ + { + id: alertId, + type: 'alert', + typeId: this.alertType.id, + relation: SAVED_OBJECT_REL_PRIMARY, }, - }, - rule: { - id: alertId, - license: this.alertType.minimumLicenseRequired, - category: this.alertType.id, - ruleset: this.alertType.producer, - }, - }; + ], + }); eventLogger.startTiming(event); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/disable.ts index 7e93cf453929b..fa94eed46dc3f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/disable.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/disable.ts @@ -14,12 +14,16 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getEventLog, } from '../../../common/lib'; +import { validateEvent } from './event_log'; // eslint-disable-next-line import/no-default-export export default function createDisableAlertTests({ getService }: FtrProviderContext) { const es = getService('es'); const supertestWithoutAuth = getService('supertestWithoutAuth'); + const retry = getService('retry'); + const supertest = getService('supertest'); describe('disable', () => { const objectRemover = new ObjectRemover(supertestWithoutAuth); @@ -75,6 +79,75 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte }); }); + it('should create recovered-instance events for all alert instances', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send({ + enabled: true, + name: 'abc', + tags: ['foo'], + rule_type_id: 'test.cumulative-firing', + consumer: 'alertsFixture', + schedule: { interval: '5s' }, + throttle: '5s', + actions: [], + params: {}, + notify_when: 'onThrottleInterval', + }) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'rule', 'alerting'); + + // wait for alert to actually execute + await retry.try(async () => { + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${createdAlert.id}/state` + ); + + expect(response.status).to.eql(200); + expect(response.body).to.key('alerts', 'rule_type_state', 'previous_started_at'); + expect(response.body.rule_type_state.runCount).to.greaterThan(1); + }); + + await alertUtils.getDisableRequest(createdAlert.id); + const ruleId = createdAlert.id; + + // wait for the events we're expecting + const events = await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id: ruleId, + provider: 'alerting', + actions: new Map([ + // make sure the counts of the # of events per type are as expected + ['recovered-instance', { equal: 2 }], + ]), + }); + }); + + const event = events[0]; + expect(event).to.be.ok(); + + validateEvent(event, { + spaceId: Spaces.space1.id, + savedObjects: [ + { type: 'alert', id: ruleId, rel: 'primary', type_id: 'test.cumulative-firing' }, + ], + message: "instance 'instance-0' has recovered due to the rule was disabled", + shouldHaveEventEnd: false, + shouldHaveTask: false, + rule: { + id: ruleId, + category: createdAlert.rule_type_id, + license: 'basic', + ruleset: 'alertsFixture', + name: 'abc', + }, + }); + }); + describe('legacy', () => { it('should handle disable alert request appropriately', async () => { const { body: createdAlert } = await supertestWithoutAuth From 19cad6d3743712995dc13a75ac9bef0fc12185ec Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 18 Oct 2021 15:07:59 -0700 Subject: [PATCH 29/54] Revert "Revert "[Upgrade Assistant] Refactor telemetry (#112177)" (#113665)" (#114804) This reverts commit c385d498874d4ca34f454e3cb9ad9e4c0e3219ae. * Add migration to remove obsolete attributes from telemetry saved object. * Refactor UA telemetry constants by extracting it from common/types. --- .../schema/xpack_plugins.json | 38 ---- x-pack/plugins/upgrade_assistant/README.md | 27 ++- .../upgrade_assistant/common/constants.ts | 3 + .../plugins/upgrade_assistant/common/types.ts | 31 --- .../index_settings/flyout.tsx | 16 +- .../deprecation_types/ml_snapshots/flyout.tsx | 8 + .../checklist_step.test.tsx.snap | 4 +- .../reindex/flyout/checklist_step.tsx | 22 ++- .../deprecation_types/reindex/table_row.tsx | 21 +- .../reindex/use_reindex_state.tsx | 4 - .../es_deprecations/es_deprecations.tsx | 22 +-- .../deprecation_details_flyout.tsx | 11 +- .../kibana_deprecations.tsx | 13 +- .../overview/backup_step/cloud_backup.tsx | 8 +- .../overview/backup_step/on_prem_backup.tsx | 11 +- .../deprecations_count_checkpoint.tsx | 5 +- .../overview/fix_logs_step/external_links.tsx | 27 ++- .../components/overview/overview.tsx | 13 +- .../public/application/lib/api.ts | 16 -- .../public/application/lib/ui_metric.ts | 49 +++++ .../upgrade_assistant/public/plugin.ts | 10 +- .../plugins/upgrade_assistant/public/types.ts | 3 +- .../lib/telemetry/es_ui_open_apis.test.ts | 48 ----- .../server/lib/telemetry/es_ui_open_apis.ts | 57 ------ .../lib/telemetry/es_ui_reindex_apis.test.ts | 52 ----- .../lib/telemetry/es_ui_reindex_apis.ts | 63 ------ .../lib/telemetry/usage_collector.test.ts | 31 --- .../server/lib/telemetry/usage_collector.ts | 113 +---------- .../upgrade_assistant/server/plugin.ts | 3 +- .../server/routes/register_routes.ts | 2 - .../server/routes/telemetry.test.ts | 187 ------------------ .../server/routes/telemetry.ts | 64 ------ .../saved_object_types/migrations/index.ts | 8 + .../telemetry_saved_object_migrations.test.ts | 41 ++++ .../telemetry_saved_object_migrations.ts | 40 ++++ .../telemetry_saved_object_type.ts | 42 +--- 36 files changed, 303 insertions(+), 810 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/public/application/lib/ui_metric.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/routes/telemetry.test.ts delete mode 100644 x-pack/plugins/upgrade_assistant/server/routes/telemetry.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/index.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.test.ts create mode 100644 x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.ts diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 882ff0257e27d..708885d43d78d 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -7330,44 +7330,6 @@ } } } - }, - "ui_open": { - "properties": { - "elasticsearch": { - "type": "long", - "_meta": { - "description": "Number of times a user viewed the list of Elasticsearch deprecations." - } - }, - "overview": { - "type": "long", - "_meta": { - "description": "Number of times a user viewed the Overview page." - } - }, - "kibana": { - "type": "long", - "_meta": { - "description": "Number of times a user viewed the list of Kibana deprecations" - } - } - } - }, - "ui_reindex": { - "properties": { - "close": { - "type": "long" - }, - "open": { - "type": "long" - }, - "start": { - "type": "long" - }, - "stop": { - "type": "long" - } - } } } }, diff --git a/x-pack/plugins/upgrade_assistant/README.md b/x-pack/plugins/upgrade_assistant/README.md index 255eb94a0318c..6570e7f8d7617 100644 --- a/x-pack/plugins/upgrade_assistant/README.md +++ b/x-pack/plugins/upgrade_assistant/README.md @@ -226,4 +226,29 @@ This is a non-exhaustive list of different error scenarios in Upgrade Assistant. - **Error updating deprecation logging status.** Mock a `404` status code to `PUT /api/upgrade_assistant/deprecation_logging`. Alternatively, edit [this line](https://github.com/elastic/kibana/blob/545c1420c285af8f5eee56f414bd6eca735aea11/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts#L77) locally and replace `deprecation_logging` with `fake_deprecation_logging`. - **Unauthorized error fetching ES deprecations.** Mock a `403` status code to `GET /api/upgrade_assistant/es_deprecations` with the response payload: `{ "statusCode": 403 }` - **Partially upgraded error fetching ES deprecations.** Mock a `426` status code to `GET /api/upgrade_assistant/es_deprecations` with the response payload: `{ "statusCode": 426, "attributes": { "allNodesUpgraded": false } }` -- **Upgraded error fetching ES deprecations.** Mock a `426` status code to `GET /api/upgrade_assistant/es_deprecations` with the response payload: `{ "statusCode": 426, "attributes": { "allNodesUpgraded": true } }` \ No newline at end of file +- **Upgraded error fetching ES deprecations.** Mock a `426` status code to `GET /api/upgrade_assistant/es_deprecations` with the response payload: `{ "statusCode": 426, "attributes": { "allNodesUpgraded": true } }` + +### Telemetry + +The Upgrade Assistant tracks several triggered events in the UI, using Kibana Usage Collection service's [UI counters](https://github.com/elastic/kibana/blob/master/src/plugins/usage_collection/README.mdx#ui-counters). + +**Overview page** +- Component loaded +- Click event for "Create snapshot" button +- Click event for "View deprecation logs in Observability" link +- Click event for "Analyze logs in Discover" link +- Click event for "Reset counter" button + +**ES deprecations page** +- Component loaded +- Click events for starting and stopping reindex tasks +- Click events for upgrading or deleting a Machine Learning snapshot +- Click event for deleting a deprecated index setting + +**Kibana deprecations page** +- Component loaded +- Click event for "Quick resolve" button + +In addition to UI counters, the Upgrade Assistant has a [custom usage collector](https://github.com/elastic/kibana/blob/master/src/plugins/usage_collection/README.mdx#custom-collector). It currently is only responsible for tracking whether the user has deprecation logging enabled or not. + +For testing instructions, refer to the [Kibana Usage Collection service README](https://github.com/elastic/kibana/blob/master/src/plugins/usage_collection/README.mdx#testing). \ No newline at end of file diff --git a/x-pack/plugins/upgrade_assistant/common/constants.ts b/x-pack/plugins/upgrade_assistant/common/constants.ts index c656df77e461f..11d81d99017d7 100644 --- a/x-pack/plugins/upgrade_assistant/common/constants.ts +++ b/x-pack/plugins/upgrade_assistant/common/constants.ts @@ -28,6 +28,9 @@ export const indexSettingDeprecations = { export const API_BASE_PATH = '/api/upgrade_assistant'; +// Telemetry constants +export const UPGRADE_ASSISTANT_TELEMETRY = 'upgrade-assistant-telemetry'; + /** * This is the repository where Cloud stores its backup snapshots. */ diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 9752fa326082a..e13af1f94d9c2 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -126,8 +126,6 @@ export interface ReindexWarning { } // Telemetry types -export const UPGRADE_ASSISTANT_TYPE = 'upgrade-assistant-telemetry'; -export const UPGRADE_ASSISTANT_DOC_ID = 'upgrade-assistant-telemetry'; export type UIOpenOption = 'overview' | 'elasticsearch' | 'kibana'; export type UIReindexOption = 'close' | 'open' | 'start' | 'stop'; @@ -144,32 +142,7 @@ export interface UIReindex { stop: boolean; } -export interface UpgradeAssistantTelemetrySavedObject { - ui_open: { - overview: number; - elasticsearch: number; - kibana: number; - }; - ui_reindex: { - close: number; - open: number; - start: number; - stop: number; - }; -} - export interface UpgradeAssistantTelemetry { - ui_open: { - overview: number; - elasticsearch: number; - kibana: number; - }; - ui_reindex: { - close: number; - open: number; - start: number; - stop: number; - }; features: { deprecation_logging: { enabled: boolean; @@ -177,10 +150,6 @@ export interface UpgradeAssistantTelemetry { }; } -export interface UpgradeAssistantTelemetrySavedObjectAttributes { - [key: string]: any; -} - export type MIGRATION_DEPRECATION_LEVEL = 'none' | 'info' | 'warning' | 'critical'; export interface DeprecationInfo { level: MIGRATION_DEPRECATION_LEVEL; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/index_settings/flyout.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/index_settings/flyout.tsx index d0aac8ee922f7..a6add8cccdd2d 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/index_settings/flyout.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/index_settings/flyout.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiButton, EuiButtonEmpty, @@ -28,6 +29,7 @@ import { IndexSettingAction, ResponseError, } from '../../../../../../common/types'; +import { uiMetricService, UIM_INDEX_SETTINGS_DELETE_CLICK } from '../../../../lib/ui_metric'; import type { Status } from '../../../types'; import { DeprecationFlyoutLearnMoreLink, DeprecationBadge } from '../../../shared'; @@ -104,6 +106,11 @@ export const RemoveIndexSettingsFlyout = ({ // Flag used to hide certain parts of the UI if the deprecation has been resolved or is in progress const isResolvable = ['idle', 'error'].includes(statusType); + const onRemoveSettings = useCallback(() => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_INDEX_SETTINGS_DELETE_CLICK); + removeIndexSettings(index!, (correctiveAction as IndexSettingAction).deprecatedSettings); + }, [correctiveAction, index, removeIndexSettings]); + return ( <> @@ -185,12 +192,7 @@ export const RemoveIndexSettingsFlyout = ({ fill data-test-subj="deleteSettingsButton" color="danger" - onClick={() => - removeIndexSettings( - index!, - (correctiveAction as IndexSettingAction).deprecatedSettings - ) - } + onClick={onRemoveSettings} > {statusType === 'error' ? i18nTexts.retryRemoveButtonLabel diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/ml_snapshots/flyout.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/ml_snapshots/flyout.tsx index c4145bf3d4146..a5830cf1ca655 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/ml_snapshots/flyout.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/ml_snapshots/flyout.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiButton, @@ -25,6 +26,11 @@ import { } from '@elastic/eui'; import { EnrichedDeprecationInfo } from '../../../../../../common/types'; +import { + uiMetricService, + UIM_ML_SNAPSHOT_UPGRADE_CLICK, + UIM_ML_SNAPSHOT_DELETE_CLICK, +} from '../../../../lib/ui_metric'; import { useAppContext } from '../../../../app_context'; import { DeprecationFlyoutLearnMoreLink, DeprecationBadge } from '../../../shared'; import { MlSnapshotContext } from './context'; @@ -167,11 +173,13 @@ export const FixSnapshotsFlyout = ({ const isResolved = snapshotState.status === 'complete'; const onUpgradeSnapshot = () => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_ML_SNAPSHOT_UPGRADE_CLICK); upgradeSnapshot(); closeFlyout(); }; const onDeleteSnapshot = () => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_ML_SNAPSHOT_DELETE_CLICK); deleteSnapshot(); closeFlyout(); }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap index be84bbf143ab3..ff2559ecd8302 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/flyout/__snapshots__/checklist_step.test.tsx.snap @@ -31,7 +31,7 @@ exports[`ChecklistFlyout renders 1`] = ` { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_REINDEX_START_CLICK); + startReindex(); + }, [startReindex]); + + const onStopReindex = useCallback(() => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_REINDEX_STOP_CLICK); + cancelReindex(); + }, [cancelReindex]); + return ( @@ -148,7 +164,7 @@ export const ChecklistFlyoutStep: React.FunctionComponent<{

- +
@@ -166,7 +182,7 @@ export const ChecklistFlyoutStep: React.FunctionComponent<{ fill color={status === ReindexStatus.paused ? 'warning' : 'primary'} iconType={status === ReindexStatus.paused ? 'play' : undefined} - onClick={startReindex} + onClick={onStartReindex} isLoading={loading} disabled={loading || !hasRequiredPrivileges} data-test-subj="startReindexingButton" diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/table_row.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/table_row.tsx index c2a14ca5be858..1059720e66a59 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/table_row.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/table_row.tsx @@ -7,9 +7,15 @@ import React, { useState, useEffect, useCallback } from 'react'; import { EuiTableRowCell } from '@elastic/eui'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EnrichedDeprecationInfo } from '../../../../../../common/types'; import { GlobalFlyout } from '../../../../../shared_imports'; import { useAppContext } from '../../../../app_context'; +import { + uiMetricService, + UIM_REINDEX_CLOSE_FLYOUT_CLICK, + UIM_REINDEX_OPEN_FLYOUT_CLICK, +} from '../../../../lib/ui_metric'; import { DeprecationTableColumns } from '../../../types'; import { EsDeprecationsTableCells } from '../../es_deprecations_table_cells'; import { ReindexResolutionCell } from './resolution_table_cell'; @@ -29,9 +35,6 @@ const ReindexTableRowCells: React.FunctionComponent = ({ }) => { const [showFlyout, setShowFlyout] = useState(false); const reindexState = useReindexContext(); - const { - services: { api }, - } = useAppContext(); const { addContent: addContentToGlobalFlyout, removeContent: removeContentFromGlobalFlyout } = useGlobalFlyout(); @@ -39,8 +42,8 @@ const ReindexTableRowCells: React.FunctionComponent = ({ const closeFlyout = useCallback(async () => { removeContentFromGlobalFlyout('reindexFlyout'); setShowFlyout(false); - await api.sendReindexTelemetryData({ close: true }); - }, [api, removeContentFromGlobalFlyout]); + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_REINDEX_CLOSE_FLYOUT_CLICK); + }, [removeContentFromGlobalFlyout]); useEffect(() => { if (showFlyout) { @@ -64,13 +67,9 @@ const ReindexTableRowCells: React.FunctionComponent = ({ useEffect(() => { if (showFlyout) { - async function sendTelemetry() { - await api.sendReindexTelemetryData({ open: true }); - } - - sendTelemetry(); + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_REINDEX_OPEN_FLYOUT_CLICK); } - }, [showFlyout, api]); + }, [showFlyout]); return ( <> diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/use_reindex_state.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/use_reindex_state.tsx index 68737d1bbff87..e3a747e6615b8 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/use_reindex_state.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/deprecation_types/reindex/use_reindex_state.tsx @@ -138,8 +138,6 @@ export const useReindexStatus = ({ indexName, api }: { indexName: string; api: A }; }); - api.sendReindexTelemetryData({ start: true }); - const { data: reindexOp, error } = await api.startReindexTask(indexName); if (error) { @@ -161,8 +159,6 @@ export const useReindexStatus = ({ indexName, api }: { indexName: string; api: A }, [api, indexName, updateStatus]); const cancelReindex = useCallback(async () => { - api.sendReindexTelemetryData({ stop: true }); - setReindexState((prevValue: ReindexState) => { return { ...prevValue, diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx index c7d157c342c77..7c3394d5a9c0f 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecations/es_deprecations.tsx @@ -12,10 +12,12 @@ import { EuiPageHeader, EuiSpacer, EuiPageContent, EuiLink } from '@elastic/eui' import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { DocLinksStart } from 'kibana/public'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EnrichedDeprecationInfo } from '../../../../common/types'; import { SectionLoading } from '../../../shared_imports'; import { useAppContext } from '../../app_context'; +import { uiMetricService, UIM_ES_DEPRECATIONS_PAGE_LOAD } from '../../lib/ui_metric'; import { getEsDeprecationError } from '../../lib/get_es_deprecation_error'; import { DeprecationsPageLoadingError, NoDeprecationsPrompt, DeprecationCount } from '../shared'; import { EsDeprecationsTable } from './es_deprecations_table'; @@ -82,13 +84,7 @@ export const EsDeprecations = withRouter(({ history }: RouteComponentProps) => { }, } = useAppContext(); - const { - data: esDeprecations, - isLoading, - error, - resendRequest, - isInitialRequest, - } = api.useLoadEsDeprecations(); + const { data: esDeprecations, isLoading, error, resendRequest } = api.useLoadEsDeprecations(); const deprecationsCountByLevel: { warningDeprecations: number; @@ -103,16 +99,8 @@ export const EsDeprecations = withRouter(({ history }: RouteComponentProps) => { }, [breadcrumbs]); useEffect(() => { - if (isLoading === false && isInitialRequest) { - async function sendTelemetryData() { - await api.sendPageTelemetryData({ - elasticsearch: true, - }); - } - - sendTelemetryData(); - } - }, [api, isLoading, isInitialRequest]); + uiMetricService.trackUiMetric(METRIC_TYPE.LOADED, UIM_ES_DEPRECATIONS_PAGE_LOAD); + }, []); if (error) { return ( diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/deprecation_details_flyout.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/deprecation_details_flyout.tsx index 5d10350caad9e..041e617d2a020 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/deprecation_details_flyout.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/deprecation_details_flyout.tsx @@ -5,9 +5,10 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiButtonEmpty, @@ -23,6 +24,7 @@ import { EuiSpacer, } from '@elastic/eui'; +import { uiMetricService, UIM_KIBANA_QUICK_RESOLVE_CLICK } from '../../lib/ui_metric'; import { DeprecationFlyoutLearnMoreLink, DeprecationBadge } from '../shared'; import type { DeprecationResolutionState, KibanaDeprecationDetails } from './kibana_deprecations'; @@ -127,6 +129,11 @@ export const DeprecationDetailsFlyout = ({ const isCurrent = deprecationResolutionState?.id === deprecation.id; const isResolved = isCurrent && deprecationResolutionState?.resolveDeprecationStatus === 'ok'; + const onResolveDeprecation = useCallback(() => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_KIBANA_QUICK_RESOLVE_CLICK); + resolveDeprecation(deprecation); + }, [deprecation, resolveDeprecation]); + return ( <> @@ -227,7 +234,7 @@ export const DeprecationDetailsFlyout = ({ resolveDeprecation(deprecation)} + onClick={onResolveDeprecation} isLoading={Boolean( deprecationResolutionState?.resolveDeprecationStatus === 'in_progress' )} diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/kibana_deprecations.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/kibana_deprecations.tsx index 013f59a7dcf56..22a67b227fd8c 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/kibana_deprecations.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/kibana_deprecations/kibana_deprecations.tsx @@ -11,10 +11,12 @@ import { withRouter, RouteComponentProps } from 'react-router-dom'; import { EuiPageContent, EuiPageHeader, EuiSpacer, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE } from '@kbn/analytics'; import type { DomainDeprecationDetails } from 'kibana/public'; import { SectionLoading, GlobalFlyout } from '../../../shared_imports'; import { useAppContext } from '../../app_context'; +import { uiMetricService, UIM_KIBANA_DEPRECATIONS_PAGE_LOAD } from '../../lib/ui_metric'; import { DeprecationsPageLoadingError, NoDeprecationsPrompt, DeprecationCount } from '../shared'; import { KibanaDeprecationsTable } from './kibana_deprecations_table'; import { @@ -116,7 +118,6 @@ export const KibanaDeprecations = withRouter(({ history }: RouteComponentProps) services: { core: { deprecations }, breadcrumbs, - api, }, } = useAppContext(); @@ -225,14 +226,8 @@ export const KibanaDeprecations = withRouter(({ history }: RouteComponentProps) ]); useEffect(() => { - async function sendTelemetryData() { - await api.sendPageTelemetryData({ - kibana: true, - }); - } - - sendTelemetryData(); - }, [api]); + uiMetricService.trackUiMetric(METRIC_TYPE.LOADED, UIM_KIBANA_DEPRECATIONS_PAGE_LOAD); + }, []); useEffect(() => { breadcrumbs.setBreadcrumbs('kibanaDeprecations'); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/cloud_backup.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/cloud_backup.tsx index 55a6ee8e5c73f..4ab860a0bf6a7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/cloud_backup.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/cloud_backup.tsx @@ -9,6 +9,7 @@ import React, { useEffect } from 'react'; import moment from 'moment-timezone'; import { FormattedDate, FormattedTime, FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiLoadingContent, EuiFlexGroup, @@ -21,6 +22,7 @@ import { } from '@elastic/eui'; import { useAppContext } from '../../../app_context'; +import { uiMetricService, UIM_BACKUP_DATA_CLOUD_CLICK } from '../../../lib/ui_metric'; interface Props { cloudSnapshotsUrl: string; @@ -128,11 +130,13 @@ export const CloudBackup: React.FunctionComponent = ({ return ( <> {statusMessage} - - + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_BACKUP_DATA_CLOUD_CLICK); + }} data-test-subj="cloudSnapshotsLink" target="_blank" iconType="popout" diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/on_prem_backup.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/on_prem_backup.tsx index 2e2e2bd5ce48e..69100b36db7eb 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/on_prem_backup.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/backup_step/on_prem_backup.tsx @@ -8,9 +8,11 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; import { useAppContext } from '../../../app_context'; +import { uiMetricService, UIM_BACKUP_DATA_ON_PREM_CLICK } from '../../../lib/ui_metric'; const SnapshotRestoreAppLink: React.FunctionComponent = () => { const { @@ -22,7 +24,14 @@ const SnapshotRestoreAppLink: React.FunctionComponent = () => { ?.useUrl({ page: 'snapshots' }); return ( - + // eslint-disable-next-line @elastic/eui/href-or-on-click + { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_BACKUP_DATA_ON_PREM_CLICK); + }} + data-test-subj="snapshotRestoreLink" + > ( @@ -90,6 +92,7 @@ export const DeprecationsCountCheckpoint: FunctionComponent = ({ } const now = moment().toISOString(); + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_RESET_LOGS_COUNTER_CLICK); setCheckpoint(now); }; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/external_links.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/external_links.tsx index 7d26e4db69713..dec43145ef966 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/external_links.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_logs_step/external_links.tsx @@ -9,10 +9,17 @@ import { encode } from 'rison-node'; import React, { FunctionComponent, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { METRIC_TYPE } from '@kbn/analytics'; import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiPanel, EuiText } from '@elastic/eui'; -import { useAppContext } from '../../../app_context'; import { DataPublicPluginStart } from '../../../../shared_imports'; +import { useAppContext } from '../../../app_context'; +import { + uiMetricService, + UIM_OBSERVABILITY_CLICK, + UIM_DISCOVER_CLICK, +} from '../../../lib/ui_metric'; + import { DEPRECATION_LOGS_INDEX_PATTERN, DEPRECATION_LOGS_SOURCE_ID, @@ -83,7 +90,14 @@ const DiscoverAppLink: FunctionComponent = ({ checkpoint }) => { }, [dataService, checkpoint, share.url.locators]); return ( - + // eslint-disable-next-line @elastic/eui/href-or-on-click + { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_DISCOVER_CLICK); + }} + data-test-subj="viewDiscoverLogs" + > = ({ checkpoint }) => { ); return ( - + // eslint-disable-next-line @elastic/eui/href-or-on-click + { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, UIM_OBSERVABILITY_CLICK); + }} + data-test-subj="viewObserveLogs" + > { kibanaVersionInfo: { nextMajor }, services: { breadcrumbs, - api, core: { docLinks }, }, plugins: { cloud }, } = useAppContext(); useEffect(() => { - async function sendTelemetryData() { - await api.sendPageTelemetryData({ - overview: true, - }); - } - - sendTelemetryData(); - }, [api]); + uiMetricService.trackUiMetric(METRIC_TYPE.LOADED, UIM_OVERVIEW_PAGE_LOAD); + }, []); useEffect(() => { breadcrumbs.setBreadcrumbs('overview'); diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts index 3342435a6d46e..8b967d994af9b 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/api.ts @@ -125,14 +125,6 @@ export class ApiService { }); } - public async sendPageTelemetryData(telemetryData: { [tabName: string]: boolean }) { - return await this.sendRequest({ - path: `${API_BASE_PATH}/stats/ui_open`, - method: 'put', - body: JSON.stringify(telemetryData), - }); - } - public useLoadDeprecationLogging() { return this.useRequest<{ isDeprecationLogIndexingEnabled: boolean; @@ -216,14 +208,6 @@ export class ApiService { }); } - public async sendReindexTelemetryData(telemetryData: { [key: string]: boolean }) { - return await this.sendRequest({ - path: `${API_BASE_PATH}/stats/ui_reindex`, - method: 'put', - body: JSON.stringify(telemetryData), - }); - } - public async getReindexStatus(indexName: string) { return await this.sendRequest({ path: `${API_BASE_PATH}/reindex/${indexName}`, diff --git a/x-pack/plugins/upgrade_assistant/public/application/lib/ui_metric.ts b/x-pack/plugins/upgrade_assistant/public/application/lib/ui_metric.ts new file mode 100644 index 0000000000000..394f046a8bafe --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/lib/ui_metric.ts @@ -0,0 +1,49 @@ +/* + * 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 { UiCounterMetricType } from '@kbn/analytics'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; + +export const UIM_APP_NAME = 'upgrade_assistant'; +export const UIM_ES_DEPRECATIONS_PAGE_LOAD = 'es_deprecations_page_load'; +export const UIM_KIBANA_DEPRECATIONS_PAGE_LOAD = 'kibana_deprecations_page_load'; +export const UIM_OVERVIEW_PAGE_LOAD = 'overview_page_load'; +export const UIM_REINDEX_OPEN_FLYOUT_CLICK = 'reindex_open_flyout_click'; +export const UIM_REINDEX_CLOSE_FLYOUT_CLICK = 'reindex_close_flyout_click'; +export const UIM_REINDEX_START_CLICK = 'reindex_start_click'; +export const UIM_REINDEX_STOP_CLICK = 'reindex_stop_click'; +export const UIM_BACKUP_DATA_CLOUD_CLICK = 'backup_data_cloud_click'; +export const UIM_BACKUP_DATA_ON_PREM_CLICK = 'backup_data_on_prem_click'; +export const UIM_RESET_LOGS_COUNTER_CLICK = 'reset_logs_counter_click'; +export const UIM_OBSERVABILITY_CLICK = 'observability_click'; +export const UIM_DISCOVER_CLICK = 'discover_click'; +export const UIM_ML_SNAPSHOT_UPGRADE_CLICK = 'ml_snapshot_upgrade_click'; +export const UIM_ML_SNAPSHOT_DELETE_CLICK = 'ml_snapshot_delete_click'; +export const UIM_INDEX_SETTINGS_DELETE_CLICK = 'index_settings_delete_click'; +export const UIM_KIBANA_QUICK_RESOLVE_CLICK = 'kibana_quick_resolve_click'; + +export class UiMetricService { + private usageCollection: UsageCollectionSetup | undefined; + + public setup(usageCollection: UsageCollectionSetup) { + this.usageCollection = usageCollection; + } + + private track(metricType: UiCounterMetricType, eventName: string | string[]) { + if (!this.usageCollection) { + // Usage collection might be disabled in Kibana config. + return; + } + return this.usageCollection.reportUiCounter(UIM_APP_NAME, metricType, eventName); + } + + public trackUiMetric(metricType: UiCounterMetricType, eventName: string | string[]) { + return this.track(metricType, eventName); + } +} + +export const uiMetricService = new UiMetricService(); diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index 64c21997b12c4..d688ee510ce1f 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -11,6 +11,7 @@ import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; import { apiService } from './application/lib/api'; import { breadcrumbService } from './application/lib/breadcrumbs'; +import { uiMetricService } from './application/lib/ui_metric'; import { SetupDependencies, StartDependencies, AppDependencies } from './types'; import { Config } from '../common/config'; @@ -18,7 +19,10 @@ export class UpgradeAssistantUIPlugin implements Plugin { constructor(private ctx: PluginInitializerContext) {} - setup(coreSetup: CoreSetup, { management, cloud, share }: SetupDependencies) { + setup( + coreSetup: CoreSetup, + { management, cloud, share, usageCollection }: SetupDependencies + ) { const { readonly } = this.ctx.config.get(); const appRegistrar = management.sections.section.stack; @@ -34,6 +38,10 @@ export class UpgradeAssistantUIPlugin defaultMessage: 'Upgrade Assistant', }); + if (usageCollection) { + uiMetricService.setup(usageCollection); + } + appRegistrar.registerApp({ id: 'upgrade_assistant', title: pluginName, diff --git a/x-pack/plugins/upgrade_assistant/public/types.ts b/x-pack/plugins/upgrade_assistant/public/types.ts index 7bb3e0869fdaa..e58c90336d856 100644 --- a/x-pack/plugins/upgrade_assistant/public/types.ts +++ b/x-pack/plugins/upgrade_assistant/public/types.ts @@ -10,7 +10,7 @@ import { ManagementSetup } from 'src/plugins/management/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { SharePluginSetup } from 'src/plugins/share/public'; import { CoreStart } from 'src/core/public'; - +import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; import { CloudSetup } from '../../cloud/public'; import { LicensingPluginStart } from '../../licensing/public'; import { BreadcrumbService } from './application/lib/breadcrumbs'; @@ -26,6 +26,7 @@ export interface SetupDependencies { management: ManagementSetup; share: SharePluginSetup; cloud?: CloudSetup; + usageCollection?: UsageCollectionSetup; } export interface StartDependencies { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts deleted file mode 100644 index caff78390b9d1..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.test.ts +++ /dev/null @@ -1,48 +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 { savedObjectsRepositoryMock } from 'src/core/server/mocks'; -import { UPGRADE_ASSISTANT_DOC_ID, UPGRADE_ASSISTANT_TYPE } from '../../../common/types'; - -import { upsertUIOpenOption } from './es_ui_open_apis'; - -/** - * Since these route callbacks are so thin, these serve simply as integration tests - * to ensure they're wired up to the lib functions correctly. Business logic is tested - * more thoroughly in the lib/telemetry tests. - */ -describe('Upgrade Assistant Telemetry SavedObject UIOpen', () => { - describe('Upsert UIOpen Option', () => { - it('call saved objects internal repository with the correct info', async () => { - const internalRepo = savedObjectsRepositoryMock.create(); - - await upsertUIOpenOption({ - overview: true, - elasticsearch: true, - kibana: true, - savedObjects: { createInternalRepository: () => internalRepo } as any, - }); - - expect(internalRepo.incrementCounter).toHaveBeenCalledTimes(3); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - ['ui_open.overview'] - ); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - ['ui_open.elasticsearch'] - ); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - ['ui_open.kibana'] - ); - }); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts deleted file mode 100644 index 3d463fe4b03ed..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_open_apis.ts +++ /dev/null @@ -1,57 +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 { SavedObjectsServiceStart } from 'src/core/server'; -import { - UIOpen, - UIOpenOption, - UPGRADE_ASSISTANT_DOC_ID, - UPGRADE_ASSISTANT_TYPE, -} from '../../../common/types'; - -interface IncrementUIOpenDependencies { - uiOpenOptionCounter: UIOpenOption; - savedObjects: SavedObjectsServiceStart; -} - -async function incrementUIOpenOptionCounter({ - savedObjects, - uiOpenOptionCounter, -}: IncrementUIOpenDependencies) { - const internalRepository = savedObjects.createInternalRepository(); - - await internalRepository.incrementCounter(UPGRADE_ASSISTANT_TYPE, UPGRADE_ASSISTANT_DOC_ID, [ - `ui_open.${uiOpenOptionCounter}`, - ]); -} - -type UpsertUIOpenOptionDependencies = UIOpen & { savedObjects: SavedObjectsServiceStart }; - -export async function upsertUIOpenOption({ - overview, - elasticsearch, - savedObjects, - kibana, -}: UpsertUIOpenOptionDependencies): Promise { - if (overview) { - await incrementUIOpenOptionCounter({ savedObjects, uiOpenOptionCounter: 'overview' }); - } - - if (elasticsearch) { - await incrementUIOpenOptionCounter({ savedObjects, uiOpenOptionCounter: 'elasticsearch' }); - } - - if (kibana) { - await incrementUIOpenOptionCounter({ savedObjects, uiOpenOptionCounter: 'kibana' }); - } - - return { - overview, - elasticsearch, - kibana, - }; -} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts deleted file mode 100644 index 6a05e8a697bb8..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.test.ts +++ /dev/null @@ -1,52 +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 { savedObjectsRepositoryMock } from 'src/core/server/mocks'; -import { UPGRADE_ASSISTANT_DOC_ID, UPGRADE_ASSISTANT_TYPE } from '../../../common/types'; -import { upsertUIReindexOption } from './es_ui_reindex_apis'; - -/** - * Since these route callbacks are so thin, these serve simply as integration tests - * to ensure they're wired up to the lib functions correctly. Business logic is tested - * more thoroughly in the lib/telemetry tests. - */ -describe('Upgrade Assistant Telemetry SavedObject UIReindex', () => { - describe('Upsert UIReindex Option', () => { - it('call saved objects internal repository with the correct info', async () => { - const internalRepo = savedObjectsRepositoryMock.create(); - await upsertUIReindexOption({ - close: true, - open: true, - start: true, - stop: true, - savedObjects: { createInternalRepository: () => internalRepo } as any, - }); - - expect(internalRepo.incrementCounter).toHaveBeenCalledTimes(4); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - [`ui_reindex.close`] - ); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - [`ui_reindex.open`] - ); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - [`ui_reindex.start`] - ); - expect(internalRepo.incrementCounter).toHaveBeenCalledWith( - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID, - [`ui_reindex.stop`] - ); - }); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts deleted file mode 100644 index caee1a58a4006..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/es_ui_reindex_apis.ts +++ /dev/null @@ -1,63 +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 { SavedObjectsServiceStart } from 'src/core/server'; -import { - UIReindex, - UIReindexOption, - UPGRADE_ASSISTANT_DOC_ID, - UPGRADE_ASSISTANT_TYPE, -} from '../../../common/types'; - -interface IncrementUIReindexOptionDependencies { - uiReindexOptionCounter: UIReindexOption; - savedObjects: SavedObjectsServiceStart; -} - -async function incrementUIReindexOptionCounter({ - savedObjects, - uiReindexOptionCounter, -}: IncrementUIReindexOptionDependencies) { - const internalRepository = savedObjects.createInternalRepository(); - - await internalRepository.incrementCounter(UPGRADE_ASSISTANT_TYPE, UPGRADE_ASSISTANT_DOC_ID, [ - `ui_reindex.${uiReindexOptionCounter}`, - ]); -} - -type UpsertUIReindexOptionDepencies = UIReindex & { savedObjects: SavedObjectsServiceStart }; - -export async function upsertUIReindexOption({ - start, - close, - open, - stop, - savedObjects, -}: UpsertUIReindexOptionDepencies): Promise { - if (close) { - await incrementUIReindexOptionCounter({ savedObjects, uiReindexOptionCounter: 'close' }); - } - - if (open) { - await incrementUIReindexOptionCounter({ savedObjects, uiReindexOptionCounter: 'open' }); - } - - if (start) { - await incrementUIReindexOptionCounter({ savedObjects, uiReindexOptionCounter: 'start' }); - } - - if (stop) { - await incrementUIReindexOptionCounter({ savedObjects, uiReindexOptionCounter: 'stop' }); - } - - return { - close, - open, - start, - stop, - }; -} diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts index 50c5b358aa5cb..34d329557f11e 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.test.ts @@ -47,26 +47,6 @@ describe('Upgrade Assistant Usage Collector', () => { }; dependencies = { usageCollection, - savedObjects: { - createInternalRepository: jest.fn().mockImplementation(() => { - return { - get: () => { - return { - attributes: { - 'ui_open.overview': 10, - 'ui_open.elasticsearch': 20, - 'ui_open.kibana': 15, - 'ui_reindex.close': 1, - 'ui_reindex.open': 4, - 'ui_reindex.start': 2, - 'ui_reindex.stop': 1, - 'ui_reindex.not_defined': 1, - }, - }; - }, - }; - }), - }, elasticsearch: { client: clusterClient, }, @@ -91,17 +71,6 @@ describe('Upgrade Assistant Usage Collector', () => { callClusterStub ); expect(upgradeAssistantStats).toEqual({ - ui_open: { - overview: 10, - elasticsearch: 20, - kibana: 15, - }, - ui_reindex: { - close: 1, - open: 4, - start: 2, - stop: 1, - }, features: { deprecation_logging: { enabled: true, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts index 56932f5e54b06..c535cd14f104d 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/telemetry/usage_collector.ts @@ -5,43 +5,14 @@ * 2.0. */ -import { get } from 'lodash'; -import { - ElasticsearchClient, - ElasticsearchServiceStart, - ISavedObjectsRepository, - SavedObjectsServiceStart, -} from 'src/core/server'; +import { ElasticsearchClient, ElasticsearchServiceStart } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { - UPGRADE_ASSISTANT_DOC_ID, - UPGRADE_ASSISTANT_TYPE, - UpgradeAssistantTelemetry, - UpgradeAssistantTelemetrySavedObject, - UpgradeAssistantTelemetrySavedObjectAttributes, -} from '../../../common/types'; +import { UpgradeAssistantTelemetry } from '../../../common/types'; import { isDeprecationLogIndexingEnabled, isDeprecationLoggingEnabled, } from '../es_deprecation_logging_apis'; -async function getSavedObjectAttributesFromRepo( - savedObjectsRepository: ISavedObjectsRepository, - docType: string, - docID: string -) { - try { - return ( - await savedObjectsRepository.get( - docType, - docID - ) - ).attributes; - } catch (e) { - return null; - } -} - async function getDeprecationLoggingStatusValue(esClient: ElasticsearchClient): Promise { try { const { body: loggerDeprecationCallResult } = await esClient.cluster.getSettings({ @@ -57,58 +28,14 @@ async function getDeprecationLoggingStatusValue(esClient: ElasticsearchClient): } } -export async function fetchUpgradeAssistantMetrics( - { client: esClient }: ElasticsearchServiceStart, - savedObjects: SavedObjectsServiceStart -): Promise { - const savedObjectsRepository = savedObjects.createInternalRepository(); - const upgradeAssistantSOAttributes = await getSavedObjectAttributesFromRepo( - savedObjectsRepository, - UPGRADE_ASSISTANT_TYPE, - UPGRADE_ASSISTANT_DOC_ID - ); +export async function fetchUpgradeAssistantMetrics({ + client: esClient, +}: ElasticsearchServiceStart): Promise { const deprecationLoggingStatusValue = await getDeprecationLoggingStatusValue( esClient.asInternalUser ); - const getTelemetrySavedObject = ( - upgradeAssistantTelemetrySavedObjectAttrs: UpgradeAssistantTelemetrySavedObjectAttributes | null - ): UpgradeAssistantTelemetrySavedObject => { - const defaultTelemetrySavedObject = { - ui_open: { - overview: 0, - elasticsearch: 0, - kibana: 0, - }, - ui_reindex: { - close: 0, - open: 0, - start: 0, - stop: 0, - }, - }; - - if (!upgradeAssistantTelemetrySavedObjectAttrs) { - return defaultTelemetrySavedObject; - } - - return { - ui_open: { - overview: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_open.overview', 0), - elasticsearch: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_open.elasticsearch', 0), - kibana: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_open.kibana', 0), - }, - ui_reindex: { - close: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_reindex.close', 0), - open: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_reindex.open', 0), - start: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_reindex.start', 0), - stop: get(upgradeAssistantTelemetrySavedObjectAttrs, 'ui_reindex.stop', 0), - }, - } as UpgradeAssistantTelemetrySavedObject; - }; - return { - ...getTelemetrySavedObject(upgradeAssistantSOAttributes), features: { deprecation_logging: { enabled: deprecationLoggingStatusValue, @@ -119,14 +46,12 @@ export async function fetchUpgradeAssistantMetrics( interface Dependencies { elasticsearch: ElasticsearchServiceStart; - savedObjects: SavedObjectsServiceStart; usageCollection: UsageCollectionSetup; } export function registerUpgradeAssistantUsageCollector({ elasticsearch, usageCollection, - savedObjects, }: Dependencies) { const upgradeAssistantUsageCollector = usageCollection.makeUsageCollector({ @@ -143,34 +68,8 @@ export function registerUpgradeAssistantUsageCollector({ }, }, }, - ui_open: { - elasticsearch: { - type: 'long', - _meta: { - description: 'Number of times a user viewed the list of Elasticsearch deprecations.', - }, - }, - overview: { - type: 'long', - _meta: { - description: 'Number of times a user viewed the Overview page.', - }, - }, - kibana: { - type: 'long', - _meta: { - description: 'Number of times a user viewed the list of Kibana deprecations', - }, - }, - }, - ui_reindex: { - close: { type: 'long' }, - open: { type: 'long' }, - start: { type: 'long' }, - stop: { type: 'long' }, - }, }, - fetch: async () => fetchUpgradeAssistantMetrics(elasticsearch, savedObjects), + fetch: async () => fetchUpgradeAssistantMetrics(elasticsearch), }); usageCollection.registerCollector(upgradeAssistantUsageCollector); diff --git a/x-pack/plugins/upgrade_assistant/server/plugin.ts b/x-pack/plugins/upgrade_assistant/server/plugin.ts index 2062cf982d15f..717f03758f825 100644 --- a/x-pack/plugins/upgrade_assistant/server/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/server/plugin.ts @@ -142,11 +142,10 @@ export class UpgradeAssistantServerPlugin implements Plugin { registerRoutes(dependencies, this.getWorker.bind(this)); if (usageCollection) { - getStartServices().then(([{ savedObjects: savedObjectsService, elasticsearch }]) => { + getStartServices().then(([{ elasticsearch }]) => { registerUpgradeAssistantUsageCollector({ elasticsearch, usageCollection, - savedObjects: savedObjectsService, }); }); } diff --git a/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts b/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts index e6a92af53b143..b6c8850376684 100644 --- a/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts +++ b/x-pack/plugins/upgrade_assistant/server/routes/register_routes.ts @@ -14,7 +14,6 @@ import { registerSystemIndicesMigrationRoutes } from './system_indices_migration import { registerESDeprecationRoutes } from './es_deprecations'; import { registerDeprecationLoggingRoutes } from './deprecation_logging'; import { registerReindexIndicesRoutes, registerBatchReindexIndicesRoutes } from './reindex_indices'; -import { registerTelemetryRoutes } from './telemetry'; import { registerUpdateSettingsRoute } from './update_index_settings'; import { registerMlSnapshotRoutes } from './ml_snapshots'; import { ReindexWorker } from '../lib/reindexing'; @@ -29,7 +28,6 @@ export function registerRoutes(dependencies: RouteDependencies, getWorker: () => registerDeprecationLoggingRoutes(dependencies); registerReindexIndicesRoutes(dependencies, getWorker); registerBatchReindexIndicesRoutes(dependencies, getWorker); - registerTelemetryRoutes(dependencies); registerUpdateSettingsRoute(dependencies); registerMlSnapshotRoutes(dependencies); // Route for cloud to retrieve the upgrade status for ES and Kibana diff --git a/x-pack/plugins/upgrade_assistant/server/routes/telemetry.test.ts b/x-pack/plugins/upgrade_assistant/server/routes/telemetry.test.ts deleted file mode 100644 index 578cceb702751..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/routes/telemetry.test.ts +++ /dev/null @@ -1,187 +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 { kibanaResponseFactory } from 'src/core/server'; -import { savedObjectsServiceMock } from 'src/core/server/mocks'; -import { createMockRouter, MockRouter, routeHandlerContextMock } from './__mocks__/routes.mock'; -import { createRequestMock } from './__mocks__/request.mock'; - -jest.mock('../lib/telemetry/es_ui_open_apis', () => ({ - upsertUIOpenOption: jest.fn(), -})); - -jest.mock('../lib/telemetry/es_ui_reindex_apis', () => ({ - upsertUIReindexOption: jest.fn(), -})); - -import { upsertUIOpenOption } from '../lib/telemetry/es_ui_open_apis'; -import { upsertUIReindexOption } from '../lib/telemetry/es_ui_reindex_apis'; -import { registerTelemetryRoutes } from './telemetry'; - -/** - * Since these route callbacks are so thin, these serve simply as integration tests - * to ensure they're wired up to the lib functions correctly. Business logic is tested - * more thoroughly in the lib/telemetry tests. - */ -describe('Upgrade Assistant Telemetry API', () => { - let routeDependencies: any; - let mockRouter: MockRouter; - beforeEach(() => { - mockRouter = createMockRouter(); - routeDependencies = { - getSavedObjectsService: () => savedObjectsServiceMock.create(), - router: mockRouter, - }; - registerTelemetryRoutes(routeDependencies); - }); - afterEach(() => jest.clearAllMocks()); - - describe('PUT /api/upgrade_assistant/stats/ui_open', () => { - it('returns correct payload with single option', async () => { - const returnPayload = { - overview: true, - elasticsearch: false, - kibana: false, - }; - - (upsertUIOpenOption as jest.Mock).mockResolvedValue(returnPayload); - - const resp = await routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_open', - })( - routeHandlerContextMock, - createRequestMock({ body: returnPayload }), - kibanaResponseFactory - ); - - expect(resp.payload).toEqual(returnPayload); - }); - - it('returns correct payload with multiple option', async () => { - const returnPayload = { - overview: true, - elasticsearch: true, - kibana: true, - }; - - (upsertUIOpenOption as jest.Mock).mockResolvedValue(returnPayload); - - const resp = await routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_open', - })( - routeHandlerContextMock, - createRequestMock({ - body: { - overview: true, - elasticsearch: true, - kibana: true, - }, - }), - kibanaResponseFactory - ); - - expect(resp.payload).toEqual(returnPayload); - }); - - it('returns an error if it throws', async () => { - (upsertUIOpenOption as jest.Mock).mockRejectedValue(new Error(`scary error!`)); - - await expect( - routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_open', - })( - routeHandlerContextMock, - createRequestMock({ - body: { - overview: false, - }, - }), - kibanaResponseFactory - ) - ).rejects.toThrowError('scary error!'); - }); - }); - - describe('PUT /api/upgrade_assistant/stats/ui_reindex', () => { - it('returns correct payload with single option', async () => { - const returnPayload = { - close: false, - open: false, - start: true, - stop: false, - }; - - (upsertUIReindexOption as jest.Mock).mockResolvedValue(returnPayload); - - const resp = await routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_reindex', - })( - routeHandlerContextMock, - createRequestMock({ - body: { - overview: false, - }, - }), - kibanaResponseFactory - ); - - expect(resp.payload).toEqual(returnPayload); - }); - - it('returns correct payload with multiple option', async () => { - const returnPayload = { - close: true, - open: true, - start: true, - stop: true, - }; - - (upsertUIReindexOption as jest.Mock).mockResolvedValue(returnPayload); - - const resp = await routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_reindex', - })( - routeHandlerContextMock, - createRequestMock({ - body: { - close: true, - open: true, - start: true, - stop: true, - }, - }), - kibanaResponseFactory - ); - - expect(resp.payload).toEqual(returnPayload); - }); - - it('returns an error if it throws', async () => { - (upsertUIReindexOption as jest.Mock).mockRejectedValue(new Error(`scary error!`)); - - await expect( - routeDependencies.router.getHandler({ - method: 'put', - pathPattern: '/api/upgrade_assistant/stats/ui_reindex', - })( - routeHandlerContextMock, - createRequestMock({ - body: { - start: false, - }, - }), - kibanaResponseFactory - ) - ).rejects.toThrowError('scary error!'); - }); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/server/routes/telemetry.ts b/x-pack/plugins/upgrade_assistant/server/routes/telemetry.ts deleted file mode 100644 index d083b38c7c240..0000000000000 --- a/x-pack/plugins/upgrade_assistant/server/routes/telemetry.ts +++ /dev/null @@ -1,64 +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 { schema } from '@kbn/config-schema'; -import { API_BASE_PATH } from '../../common/constants'; -import { upsertUIOpenOption } from '../lib/telemetry/es_ui_open_apis'; -import { upsertUIReindexOption } from '../lib/telemetry/es_ui_reindex_apis'; -import { RouteDependencies } from '../types'; - -export function registerTelemetryRoutes({ router, getSavedObjectsService }: RouteDependencies) { - router.put( - { - path: `${API_BASE_PATH}/stats/ui_open`, - validate: { - body: schema.object({ - overview: schema.boolean({ defaultValue: false }), - elasticsearch: schema.boolean({ defaultValue: false }), - kibana: schema.boolean({ defaultValue: false }), - }), - }, - }, - async (ctx, request, response) => { - const { elasticsearch, overview, kibana } = request.body; - return response.ok({ - body: await upsertUIOpenOption({ - savedObjects: getSavedObjectsService(), - elasticsearch, - overview, - kibana, - }), - }); - } - ); - - router.put( - { - path: `${API_BASE_PATH}/stats/ui_reindex`, - validate: { - body: schema.object({ - close: schema.boolean({ defaultValue: false }), - open: schema.boolean({ defaultValue: false }), - start: schema.boolean({ defaultValue: false }), - stop: schema.boolean({ defaultValue: false }), - }), - }, - }, - async (ctx, request, response) => { - const { close, open, start, stop } = request.body; - return response.ok({ - body: await upsertUIReindexOption({ - savedObjects: getSavedObjectsService(), - close, - open, - start, - stop, - }), - }); - } - ); -} diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/index.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/index.ts new file mode 100644 index 0000000000000..5e6e379bd9b2b --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { telemetrySavedObjectMigrations } from './telemetry_saved_object_migrations'; diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.test.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.test.ts new file mode 100644 index 0000000000000..e1250ee0ebfe0 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { telemetrySavedObjectMigrations } from './telemetry_saved_object_migrations'; + +describe('Telemetry saved object migration', () => { + describe('7.16.0', () => { + test('removes ui_open and ui_reindex attributes while preserving other attributes', () => { + const doc = { + type: 'upgrade-assistant-telemetry', + id: 'upgrade-assistant-telemetry', + attributes: { + 'test.property': 5, + 'ui_open.cluster': 1, + 'ui_open.indices': 1, + 'ui_open.overview': 1, + 'ui_reindex.close': 1, + 'ui_reindex.open': 1, + 'ui_reindex.start': 1, + 'ui_reindex.stop': 1, + }, + references: [], + updated_at: '2021-09-29T21:17:17.410Z', + migrationVersion: {}, + }; + + expect(telemetrySavedObjectMigrations['7.16.0'](doc)).toStrictEqual({ + type: 'upgrade-assistant-telemetry', + id: 'upgrade-assistant-telemetry', + attributes: { 'test.property': 5 }, + references: [], + updated_at: '2021-09-29T21:17:17.410Z', + migrationVersion: {}, + }); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.ts new file mode 100644 index 0000000000000..88540d67b13df --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/migrations/telemetry_saved_object_migrations.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { get, omit, flow, some } from 'lodash'; +import type { SavedObjectMigrationFn } from 'kibana/server'; + +const v716RemoveUnusedTelemetry: SavedObjectMigrationFn = (doc) => { + // Dynamically defined in 6.7 (https://github.com/elastic/kibana/pull/28878) + // and then statically defined in 7.8 (https://github.com/elastic/kibana/pull/64332). + const attributesBlocklist = [ + 'ui_open.cluster', + 'ui_open.indices', + 'ui_open.overview', + 'ui_reindex.close', + 'ui_reindex.open', + 'ui_reindex.start', + 'ui_reindex.stop', + ]; + + const isDocEligible = some(attributesBlocklist, (attribute: string) => { + return get(doc, 'attributes', attribute); + }); + + if (isDocEligible) { + return { + ...doc, + attributes: omit(doc.attributes, attributesBlocklist), + }; + } + + return doc; +}; + +export const telemetrySavedObjectMigrations = { + '7.16.0': flow(v716RemoveUnusedTelemetry), +}; diff --git a/x-pack/plugins/upgrade_assistant/server/saved_object_types/telemetry_saved_object_type.ts b/x-pack/plugins/upgrade_assistant/server/saved_object_types/telemetry_saved_object_type.ts index 42d5d339dd050..43cf6c30fccab 100644 --- a/x-pack/plugins/upgrade_assistant/server/saved_object_types/telemetry_saved_object_type.ts +++ b/x-pack/plugins/upgrade_assistant/server/saved_object_types/telemetry_saved_object_type.ts @@ -7,50 +7,15 @@ import { SavedObjectsType } from 'src/core/server'; -import { UPGRADE_ASSISTANT_TYPE } from '../../common/types'; +import { UPGRADE_ASSISTANT_TELEMETRY } from '../../common/constants'; +import { telemetrySavedObjectMigrations } from './migrations'; export const telemetrySavedObjectType: SavedObjectsType = { - name: UPGRADE_ASSISTANT_TYPE, + name: UPGRADE_ASSISTANT_TELEMETRY, hidden: false, namespaceType: 'agnostic', mappings: { properties: { - ui_open: { - properties: { - overview: { - type: 'long', - null_value: 0, - }, - elasticsearch: { - type: 'long', - null_value: 0, - }, - kibana: { - type: 'long', - null_value: 0, - }, - }, - }, - ui_reindex: { - properties: { - close: { - type: 'long', - null_value: 0, - }, - open: { - type: 'long', - null_value: 0, - }, - start: { - type: 'long', - null_value: 0, - }, - stop: { - type: 'long', - null_value: 0, - }, - }, - }, features: { properties: { deprecation_logging: { @@ -65,4 +30,5 @@ export const telemetrySavedObjectType: SavedObjectsType = { }, }, }, + migrations: telemetrySavedObjectMigrations, }; From 2f2f395040e79af5035598d9b14c70ba8c27fa83 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 19:41:25 -0400 Subject: [PATCH 30/54] [Exploratory View] Added step level filtering/breakdowns (#115182) (#115456) Co-authored-by: Dominique Clarke Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Shahzad Co-authored-by: Dominique Clarke --- .../configurations/constants/constants.ts | 3 + .../constants/field_names/synthetics.ts | 2 + .../configurations/constants/labels.ts | 14 ++++ .../configurations/lens_attributes.ts | 16 ++++- .../synthetics/data_distribution_config.ts | 6 +- .../synthetics/field_formats.ts | 14 ++++ .../synthetics/kpi_over_time_config.ts | 54 +++++++++++++- .../test_data/sample_attribute_cwv.ts | 3 +- .../series_editor/breakdown/breakdowns.tsx | 49 +++++++++++-- .../columns/incomplete_badge.tsx | 9 ++- .../columns/report_definition_col.tsx | 70 ++++++++++++++++--- .../columns/report_definition_field.tsx | 47 +++++++++---- .../series_editor/expanded_series_row.tsx | 6 +- .../shared/exploratory_view/types.ts | 10 ++- .../field_value_combobox.tsx | 2 + .../shared/field_value_suggestions/index.tsx | 5 +- .../shared/field_value_suggestions/types.ts | 1 + .../common/header/action_menu_content.tsx | 5 +- 18 files changed, 268 insertions(+), 48 deletions(-) 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 e4473b183d729..c12e67bc9b1ae 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 eff73d242de75..0f28648552728 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 cdaa89fc71389..599f846af2ff9 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 38c9ecc06491d..a31bef7c9c214 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 da90f45d15201..fb44da8e4327f 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 5f8a6a28ca81d..f3e3fe0817845 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 6df9cdcd0503a..8951ffcda63d8 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 adc6d4bb14462..4563509eeb19a 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 a235cbd8852ad..f30a80f87ebb7 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 4e1c385921908..8e64f4bcea680 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 fbd7c34303d94..a665ec1999133 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 01f36e85c03ae..f3e0eb767d336 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 180be1ac0414f..12e0ceca20649 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 001664cf12783..acd49fc25588e 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 0735df53888aa..e04d5463d5494 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 6f6d520a83154..95b24aa69b1e7 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/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 21ef3428696e9..bcfbf18c93cf5 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', }, From 83a80c0b52e8cdbe6ffc0e99cbb240cab1fecf80 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Mon, 18 Oct 2021 19:44:52 -0400 Subject: [PATCH 31/54] [7.x] [Dashboard] Use SavedObjectResolve (#111040) (#115454) * [Dashboard] Use SavedObjectResolve (#111040) * Switch Dashboard to use savedobjects.resolve when loading * Don't use LegacyURI Redirect if in screenshot mode * Pass query string on redirects * Remove unused import * Fix carrying query params through redirect Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Fix issue with importing json dashboards Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/dashboard/kibana.json | 8 +- .../public/application/dashboard_app.tsx | 15 ++- .../public/application/dashboard_router.tsx | 3 + .../hooks/use_dashboard_app_state.ts | 23 +++++ .../lib/load_saved_dashboard_state.ts | 5 +- src/plugins/dashboard/public/plugin.tsx | 2 + .../saved_dashboards/saved_dashboard.ts | 93 ++++++++++++++----- .../saved_dashboards/saved_dashboards.ts | 6 +- src/plugins/dashboard/public/types.ts | 4 + 9 files changed, 134 insertions(+), 25 deletions(-) diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 164be971d22b7..9def8156d1bec 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -19,7 +19,13 @@ "presentationUtil", "visualizations" ], - "optionalPlugins": ["home", "spaces", "savedObjectsTaggingOss", "usageCollection"], + "optionalPlugins": [ + "home", + "spaces", + "savedObjectsTaggingOss", + "screenshotMode", + "usageCollection" + ], "server": true, "ui": true, "requiredBundles": ["home", "kibanaReact", "kibanaUtils", "presentationUtil"] diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index dcaf541619d6f..3e6566f0da0a4 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -21,6 +21,7 @@ import { EmbeddableRenderer } from '../services/embeddable'; import { DashboardTopNav, isCompleteDashboardAppState } from './top_nav/dashboard_top_nav'; import { DashboardAppServices, DashboardEmbedSettings, DashboardRedirect } from '../types'; import { createKbnUrlStateStorage, withNotifyOnErrors } from '../services/kibana_utils'; +import { createDashboardEditUrl } from '../dashboard_constants'; export interface DashboardAppProps { history: History; savedDashboardId?: string; @@ -34,7 +35,7 @@ export function DashboardApp({ redirectTo, history, }: DashboardAppProps) { - const { core, chrome, embeddable, onAppLeave, uiSettings, data } = + const { core, chrome, embeddable, onAppLeave, uiSettings, data, spacesService } = useKibana().services; const kbnUrlStateStorage = useMemo( @@ -109,6 +110,18 @@ export function DashboardApp({ embedSettings={embedSettings} dashboardAppState={dashboardAppState} /> + + {dashboardAppState.savedDashboard.outcome === 'conflict' && + dashboardAppState.savedDashboard.id && + dashboardAppState.savedDashboard.aliasId + ? spacesService?.ui.components.getLegacyUrlConflict({ + currentObjectId: dashboardAppState.savedDashboard.id, + otherObjectId: dashboardAppState.savedDashboard.aliasId, + otherObjectPath: `#${createDashboardEditUrl( + dashboardAppState.savedDashboard.aliasId + )}${history.location.search}`, + }) + : null}
diff --git a/src/plugins/dashboard/public/application/dashboard_router.tsx b/src/plugins/dashboard/public/application/dashboard_router.tsx index cf9152076b847..cb4618256536d 100644 --- a/src/plugins/dashboard/public/application/dashboard_router.tsx +++ b/src/plugins/dashboard/public/application/dashboard_router.tsx @@ -85,6 +85,7 @@ export async function mountApp({ savedObjectsTaggingOss, visualizations, presentationUtil, + screenshotMode, } = pluginsStart; const activeSpaceId = @@ -130,6 +131,8 @@ export async function mountApp({ core.notifications.toasts, activeSpaceId || 'default' ), + spacesService: spacesApi, + screenshotModeService: screenshotMode, }; const getUrlStateStorage = (history: RouteComponentProps['history']) => diff --git a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts index 99ba9704d518c..66a062f27df51 100644 --- a/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts +++ b/src/plugins/dashboard/public/application/hooks/use_dashboard_app_state.ts @@ -92,6 +92,8 @@ export const useDashboardAppState = ({ dashboardCapabilities, dashboardSessionStorage, scopedHistory, + spacesService, + screenshotModeService, } = services; const { docTitle } = chrome; const { notifications } = core; @@ -149,6 +151,25 @@ export const useDashboardAppState = ({ if (canceled || !loadSavedDashboardResult) return; const { savedDashboard, savedDashboardState } = loadSavedDashboardResult; + // If the saved dashboard is an alias match, then we will redirect + if (savedDashboard.outcome === 'aliasMatch' && savedDashboard.id && savedDashboard.aliasId) { + // We want to keep the "query" params on our redirect. + // But, these aren't true query params, they are technically part of the hash + // So, to get the new path, we will just replace the current id in the hash + // with the alias id + const path = scopedHistory().location.hash.replace( + savedDashboard.id, + savedDashboard.aliasId + ); + if (screenshotModeService?.isScreenshotMode()) { + scopedHistory().replace(path); + } else { + await spacesService?.ui.redirectLegacyUrl(path); + } + // Return so we don't run any more of the hook and let it rerun after the redirect that just happened + return; + } + /** * Combine initial state from the saved object, session storage, and URL, then dispatch it to Redux. */ @@ -340,6 +361,8 @@ export const useDashboardAppState = ({ search, query, data, + spacesService?.ui, + screenshotModeService, ]); /** diff --git a/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts b/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts index fcff740c48e15..18bc660a25f41 100644 --- a/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts +++ b/src/plugins/dashboard/public/application/lib/load_saved_dashboard_state.ts @@ -54,7 +54,10 @@ export const loadSavedDashboardState = async ({ await indexPatterns.ensureDefaultDataView(); let savedDashboard: DashboardSavedObject | undefined; try { - savedDashboard = (await savedDashboards.get(savedDashboardId)) as DashboardSavedObject; + savedDashboard = (await savedDashboards.get({ + id: savedDashboardId, + useResolve: true, + })) as DashboardSavedObject; } catch (error) { // E.g. a corrupt or deleted dashboard notifications.toasts.addDanger(error.message); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index a8a60dae5078b..a39cf8c0a83c7 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -12,6 +12,7 @@ import { filter, map } from 'rxjs/operators'; import { Start as InspectorStartContract } from 'src/plugins/inspector/public'; import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { ScreenshotModePluginStart } from 'src/plugins/screenshot_mode/public'; import { APP_WRAPPER_CLASS } from '../../../core/public'; import { App, @@ -118,6 +119,7 @@ export interface DashboardStartDependencies { savedObjects: SavedObjectsStart; presentationUtil: PresentationUtilPluginStart; savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; + screenshotMode?: ScreenshotModePluginStart; spaces?: SpacesPluginStart; visualizations: VisualizationsStart; } diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index b81cf57bbc963..3323d5f1afff1 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import { assign, cloneDeep } from 'lodash'; +import { SavedObjectsClientContract } from 'kibana/public'; import { EmbeddableStart } from '../services/embeddable'; import { SavedObject, SavedObjectsStart } from '../services/saved_objects'; import { Filter, ISearchSource, Query, RefreshInterval } from '../services/data'; @@ -32,12 +34,33 @@ export interface DashboardSavedObject extends SavedObject { getQuery(): Query; getFilters(): Filter[]; getFullEditPath: (editMode?: boolean) => string; + outcome?: string; + aliasId?: string; } +const defaults = { + title: '', + hits: 0, + description: '', + panelsJSON: '[]', + optionsJSON: JSON.stringify({ + // for BWC reasons we can't default dashboards that already exist without this setting to true. + useMargins: true, + syncColors: false, + hidePanelTitles: false, + } as DashboardOptions), + version: 1, + timeRestore: false, + timeTo: undefined, + timeFrom: undefined, + refreshInterval: undefined, +}; + // Used only by the savedDashboards service, usually no reason to change this export function createSavedDashboardClass( savedObjectStart: SavedObjectsStart, - embeddableStart: EmbeddableStart + embeddableStart: EmbeddableStart, + savedObjectsClient: SavedObjectsClientContract ): new (id: string) => DashboardSavedObject { class SavedDashboard extends savedObjectStart.SavedObjectClass { // save these objects with the 'dashboard' type @@ -68,7 +91,10 @@ export function createSavedDashboardClass( public static searchSource = true; public showInRecentlyAccessed = true; - constructor(id: string) { + public outcome?: string; + public aliasId?: string; + + constructor(arg: { id: string; useResolve: boolean } | string) { super({ type: SavedDashboard.type, mapping: SavedDashboard.mapping, @@ -88,28 +114,53 @@ export function createSavedDashboardClass( }, // if this is null/undefined then the SavedObject will be assigned the defaults - id, + id: typeof arg === 'string' || arg === undefined ? arg : arg.id, // default values that will get assigned if the doc is new - defaults: { - title: '', - hits: 0, - description: '', - panelsJSON: '[]', - optionsJSON: JSON.stringify({ - // for BWC reasons we can't default dashboards that already exist without this setting to true. - useMargins: true, - syncColors: false, - hidePanelTitles: false, - } as DashboardOptions), - version: 1, - timeRestore: false, - timeTo: undefined, - timeFrom: undefined, - refreshInterval: undefined, - }, + defaults, }); - this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.id)}`; + + const id: string = typeof arg === 'string' || arg === undefined ? arg : arg.id; + const useResolve = typeof arg === 'string' || arg === undefined ? false : arg.useResolve; + + this.getFullPath = () => `/app/dashboards#${createDashboardEditUrl(this.aliasId || this.id)}`; + + // Overwrite init if we want to use resolve + if (useResolve || true) { + this.init = async () => { + const esType = SavedDashboard.type; + // ensure that the esType is defined + if (!esType) throw new Error('You must define a type name to use SavedObject objects.'); + + if (!id) { + // just assign the defaults and be done + assign(this, defaults); + await this.hydrateIndexPattern!(); + + return this; + } + + const { + outcome, + alias_target_id: aliasId, + saved_object: resp, + } = await savedObjectsClient.resolve(esType, id); + + const respMapped = { + _id: resp.id, + _type: resp.type, + _source: cloneDeep(resp.attributes), + references: resp.references, + found: !!resp._version, + }; + + this.outcome = outcome; + this.aliasId = aliasId; + await this.applyESResp(respMapped); + + return this; + }; + } } getQuery() { diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts index 014af306a3842..94877b6c3c823 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboards.ts @@ -27,6 +27,10 @@ export function createSavedDashboardLoader({ savedObjectsClient, embeddableStart, }: Services) { - const SavedDashboard = createSavedDashboardClass(savedObjects, embeddableStart); + const SavedDashboard = createSavedDashboardClass( + savedObjects, + embeddableStart, + savedObjectsClient + ); return new SavedObjectLoader(SavedDashboard, savedObjectsClient); } diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index 54d7898b4254f..88301ac04dcd1 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -19,6 +19,7 @@ import type { import { History } from 'history'; import { AnyAction, Dispatch } from 'redux'; import { BehaviorSubject, Subject } from 'rxjs'; +import { ScreenshotModePluginStart } from 'src/plugins/screenshot_mode/public'; import { Query, Filter, IndexPattern, RefreshInterval, TimeRange } from './services/data'; import { ContainerInput, EmbeddableInput, ViewMode } from './services/embeddable'; import { SharePluginStart } from './services/share'; @@ -35,6 +36,7 @@ import { IKbnUrlStateStorage } from './services/kibana_utils'; import { DashboardContainer, DashboardSavedObject } from '.'; import { VisualizationsStart } from '../../visualizations/public'; import { DashboardAppLocatorParams } from './locator'; +import { SpacesPluginStart } from './services/spaces'; export { SavedDashboardPanel }; @@ -203,4 +205,6 @@ export interface DashboardAppServices { dashboardSessionStorage: DashboardSessionStorage; setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; savedQueryService: DataPublicPluginStart['query']['savedQueries']; + spacesService?: SpacesPluginStart; + screenshotModeService?: ScreenshotModePluginStart; } From 841de9dd1443eb7553405f7604093af25ff13ac2 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 19 Oct 2021 01:46:12 +0200 Subject: [PATCH 32/54] [7.x] [Discover] Improve context code (#114284) (#115375) * [Discover] Improve context code (#114284) # Conflicts: # src/plugins/discover/public/application/apps/context/services/context.ts * Update context.ts --- .../__mocks__/index_pattern_with_timefield.ts | 2 + .../apps/context/context_app.test.tsx | 1 - .../application/apps/context/context_app.tsx | 22 +- .../apps/context/context_app_content.tsx | 8 +- .../apps/context/context_app_route.tsx | 2 +- .../apps/context/services/_stubs.ts | 13 - .../apps/context/services/anchor.test.ts | 49 ++-- .../apps/context/services/anchor.ts | 51 ++-- .../services/context.predecessors.test.ts | 236 +++++++----------- .../services/context.successors.test.ts | 225 ++++++----------- .../apps/context/services/context.ts | 155 ++++++------ .../context/services/context_state.test.ts | 31 +-- .../apps/context/services/context_state.ts | 26 +- .../utils/use_context_app_fetch.test.ts | 33 ++- .../context/utils/use_context_app_fetch.tsx | 47 ++-- .../context/utils/use_context_app_state.ts | 12 +- 16 files changed, 343 insertions(+), 570 deletions(-) diff --git a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts index ac30c24ef6f08..6cf8e8b3485ff 100644 --- a/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/index_pattern_with_timefield.ts @@ -72,6 +72,8 @@ const indexPattern = { getFieldByName: (name: string) => fields.getByName(name), timeFieldName: 'timestamp', getFormatterForField: () => ({ convert: () => 'formatted' }), + isTimeNanosBased: () => false, + popularizeField: () => {}, } as unknown as IndexPattern; indexPattern.isTimeBased = () => !!indexPattern.timeFieldName; diff --git a/src/plugins/discover/public/application/apps/context/context_app.test.tsx b/src/plugins/discover/public/application/apps/context/context_app.test.tsx index d54a4f8bed247..0e50f8f714a2c 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.test.tsx @@ -26,7 +26,6 @@ const mockNavigationPlugin = { ui: { TopNavMenu: mockTopNavMenu } }; describe('ContextApp test', () => { const defaultProps = { indexPattern: indexPatternMock, - indexPatternId: 'the-index-pattern-id', anchorId: 'mocked_anchor_id', }; diff --git a/src/plugins/discover/public/application/apps/context/context_app.tsx b/src/plugins/discover/public/application/apps/context/context_app.tsx index 070391edae71c..9d39c93d250f2 100644 --- a/src/plugins/discover/public/application/apps/context/context_app.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app.tsx @@ -12,7 +12,7 @@ import classNames from 'classnames'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiPageContent, EuiPage, EuiSpacer } from '@elastic/eui'; import { cloneDeep } from 'lodash'; -import { esFilters, SortDirection } from '../../../../../data/public'; +import { esFilters } from '../../../../../data/public'; import { DOC_TABLE_LEGACY, SEARCH_FIELDS_FROM_SOURCE } from '../../../../common'; import { ContextErrorMessage } from './components/context_error_message'; import { IndexPattern, IndexPatternField } from '../../../../../data/common'; @@ -31,21 +31,20 @@ const ContextAppContentMemoized = memo(ContextAppContent); export interface ContextAppProps { indexPattern: IndexPattern; - indexPatternId: string; anchorId: string; } -export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAppProps) => { +export const ContextApp = ({ indexPattern, anchorId }: ContextAppProps) => { const services = getServices(); - const { uiSettings: config, capabilities, indexPatterns, navigation, filterManager } = services; + const { uiSettings, capabilities, indexPatterns, navigation, filterManager } = services; - const isLegacy = useMemo(() => config.get(DOC_TABLE_LEGACY), [config]); - const useNewFieldsApi = useMemo(() => !config.get(SEARCH_FIELDS_FROM_SOURCE), [config]); + const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); + const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); /** * Context app state */ - const { appState, setAppState } = useContextAppState({ indexPattern, services }); + const { appState, setAppState } = useContextAppState({ services }); const prevAppState = useRef(); /** @@ -54,7 +53,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { fetchedState, fetchContextRows, fetchAllRows, fetchSurroundingRows } = useContextAppFetch( { anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, @@ -79,7 +77,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp prevAppState.current = cloneDeep(appState); }, [ appState, - indexPatternId, anchorId, fetchContextRows, fetchAllRows, @@ -89,7 +86,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp const { columns, onAddColumn, onRemoveColumn, onSetColumns } = useDataGridColumns({ capabilities, - config, + config: uiSettings, indexPattern, indexPatterns, state: appState, @@ -112,7 +109,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp field, values, operation, - indexPatternId + indexPattern.id! ); filterManager.addFilters(newFilters); if (indexPatterns) { @@ -120,7 +117,7 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp await popularizeField(indexPattern, fieldName, indexPatterns, capabilities); } }, - [filterManager, indexPatternId, indexPatterns, indexPattern, capabilities] + [filterManager, indexPatterns, indexPattern, capabilities] ); const TopNavMenu = navigation.ui.TopNavMenu; @@ -166,7 +163,6 @@ export const ContextApp = ({ indexPattern, indexPatternId, anchorId }: ContextAp onAddColumn={onAddColumn} onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} - sort={appState.sort as [[string, SortDirection]]} predecessorCount={appState.predecessorCount} successorCount={appState.successorCount} setAppState={setAppState} diff --git a/src/plugins/discover/public/application/apps/context/context_app_content.tsx b/src/plugins/discover/public/application/apps/context/context_app_content.tsx index 19b6bfac2876c..2d4d3cab250f3 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_content.tsx @@ -22,6 +22,7 @@ import { DiscoverServices } from '../../../build_services'; import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from './utils/constants'; import { DocTableContext } from '../main/components/doc_table/doc_table_context'; import { EsHitRecordList } from '../../types'; +import { SortPairArr } from '../main/components/doc_table/lib/get_sort'; export interface ContextAppContentProps { columns: string[]; @@ -33,7 +34,6 @@ export interface ContextAppContentProps { predecessorCount: number; successorCount: number; rows: EsHitRecordList; - sort: [[string, SortDirection]]; predecessors: EsHitRecordList; successors: EsHitRecordList; anchorStatus: LoadingStatus; @@ -65,7 +65,6 @@ export function ContextAppContent({ predecessorCount, successorCount, rows, - sort, predecessors, successors, anchorStatus, @@ -111,6 +110,9 @@ export function ContextAppContent({ }, [setAppState] ); + const sort = useMemo(() => { + return [[indexPattern.timeFieldName!, SortDirection.desc]]; + }, [indexPattern]); return ( @@ -149,7 +151,7 @@ export function ContextAppContent({ expandedDoc={expandedDoc} isLoading={isAnchorLoading} sampleSize={0} - sort={sort} + sort={sort as SortPairArr[]} isSortEnabled={false} showTimeCol={showTimeCol} services={services} diff --git a/src/plugins/discover/public/application/apps/context/context_app_route.tsx b/src/plugins/discover/public/application/apps/context/context_app_route.tsx index 4bade3d03d993..d124fd6cfa395 100644 --- a/src/plugins/discover/public/application/apps/context/context_app_route.tsx +++ b/src/plugins/discover/public/application/apps/context/context_app_route.tsx @@ -49,5 +49,5 @@ export function ContextAppRoute(props: ContextAppProps) { return ; } - return ; + return ; } diff --git a/src/plugins/discover/public/application/apps/context/services/_stubs.ts b/src/plugins/discover/public/application/apps/context/services/_stubs.ts index 7e1473b876afc..e8d09e548c07a 100644 --- a/src/plugins/discover/public/application/apps/context/services/_stubs.ts +++ b/src/plugins/discover/public/application/apps/context/services/_stubs.ts @@ -9,7 +9,6 @@ import sinon from 'sinon'; import moment from 'moment'; -import { IndexPatternsContract } from '../../../../../../data/public'; import { EsHitRecordList } from '../../../types'; type SortHit = { @@ -18,18 +17,6 @@ type SortHit = { sort: [number, number]; }; -export function createIndexPatternsStub() { - return { - get: sinon.spy((indexPatternId) => - Promise.resolve({ - id: indexPatternId, - isTimeNanosBased: () => false, - popularizeField: () => {}, - }) - ), - } as unknown as IndexPatternsContract; -} - /** * A stubbed search source with a `fetch` method that returns all of `_stubHits`. */ diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts index b4a76fa45ec2f..8886c8ab11f64 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.test.ts @@ -6,30 +6,29 @@ * Side Public License, v 1. */ -import { EsQuerySortValue, SortDirection } from '../../../../../../data/public'; -import { createIndexPatternsStub, createSearchSourceStub } from './_stubs'; -import { fetchAnchorProvider, updateSearchSource } from './anchor'; +import { IndexPattern, SortDirection } from '../../../../../../data/public'; +import { createSearchSourceStub } from './_stubs'; +import { fetchAnchor, updateSearchSource } from './anchor'; import { indexPatternMock } from '../../../../__mocks__/index_pattern'; import { savedSearchMock } from '../../../../__mocks__/saved_search'; -import { EsHitRecord, EsHitRecordList } from '../../../types'; +import { EsHitRecordList } from '../../../types'; describe('context app', function () { - let fetchAnchor: ( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let searchSourceStub: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchAnchor', function () { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub); }); it('should use the `fetch` method of the SearchSource', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -38,7 +37,7 @@ describe('context app', function () { }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -49,7 +48,7 @@ describe('context app', function () { }); it('should set the SearchSource index pattern', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -59,7 +58,7 @@ describe('context app', function () { }); it('should set the SearchSource version flag to true', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -70,7 +69,7 @@ describe('context app', function () { }); it('should set the SearchSource size to 1', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -81,7 +80,7 @@ describe('context app', function () { }); it('should set the SearchSource query to an ids query', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -103,7 +102,7 @@ describe('context app', function () { }); it('should set the SearchSource sort order', function () { - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then(() => { @@ -145,7 +144,7 @@ describe('context app', function () { it('should reject with an error when no hits were found', function () { searchSourceStub._stubHits = []; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then( @@ -161,7 +160,7 @@ describe('context app', function () { it('should return the first hit after adding an anchor marker', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + return fetchAnchor('id', indexPattern, searchSourceStub, [ { '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }, ]).then((anchorDocument) => { @@ -174,16 +173,18 @@ describe('context app', function () { describe('useNewFields API', () => { beforeEach(() => { searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }] as unknown as EsHitRecordList); - fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub, true); }); it('should request fields if useNewFieldsApi set', function () { searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; - return fetchAnchor('INDEX_PATTERN_ID', 'id', [ - { '@timestamp': SortDirection.desc }, - { _doc: SortDirection.desc }, - ]).then(() => { + return fetchAnchor( + 'id', + indexPattern, + searchSourceStub, + [{ '@timestamp': SortDirection.desc }, { _doc: SortDirection.desc }], + true + ).then(() => { const setFieldsSpy = searchSourceStub.setField.withArgs('fields'); const removeFieldsSpy = searchSourceStub.removeField.withArgs('fieldsFromSource'); expect(setFieldsSpy.calledOnce).toBe(true); diff --git a/src/plugins/discover/public/application/apps/context/services/anchor.ts b/src/plugins/discover/public/application/apps/context/services/anchor.ts index 2d64f7526ffdd..f262d440b8a28 100644 --- a/src/plugins/discover/public/application/apps/context/services/anchor.ts +++ b/src/plugins/discover/public/application/apps/context/services/anchor.ts @@ -6,46 +6,35 @@ * Side Public License, v 1. */ -import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { - ISearchSource, - IndexPatternsContract, - EsQuerySortValue, - IndexPattern, -} from '../../../../../../data/public'; +import { ISearchSource, EsQuerySortValue, IndexPattern } from '../../../../../../data/public'; import { EsHitRecord } from '../../../types'; -export function fetchAnchorProvider( - indexPatterns: IndexPatternsContract, +export async function fetchAnchor( + anchorId: string, + indexPattern: IndexPattern, searchSource: ISearchSource, + sort: EsQuerySortValue[], useNewFieldsApi: boolean = false -) { - return async function fetchAnchor( - indexPatternId: string, - anchorId: string, - sort: EsQuerySortValue[] - ): Promise { - const indexPattern = await indexPatterns.get(indexPatternId); - updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); +): Promise { + updateSearchSource(searchSource, anchorId, sort, useNewFieldsApi, indexPattern); - const response = await searchSource.fetch(); - const doc = get(response, ['hits', 'hits', 0]); + const response = await searchSource.fetch(); + const doc = response.hits?.hits?.[0]; - if (!doc) { - throw new Error( - i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { - defaultMessage: 'Failed to load anchor document.', - }) - ); - } + if (!doc) { + throw new Error( + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { + defaultMessage: 'Failed to load anchor document.', + }) + ); + } - return { - ...doc, - isAnchor: true, - } as EsHitRecord; - }; + return { + ...doc, + isAnchor: true, + } as EsHitRecord; } export function updateSearchSource( diff --git a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts index 028dec7b9fe19..9bcf6f9c90d2c 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.predecessors.test.ts @@ -8,9 +8,9 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; -import { fetchContextProvider, SurrDocType } from './context'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; @@ -30,9 +30,6 @@ interface Timestamp { describe('context predecessors', function () { let fetchPredecessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -41,6 +38,12 @@ describe('context predecessors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchPredecessors', function () { beforeEach(() => { @@ -56,30 +59,20 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -95,19 +88,12 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -119,33 +105,26 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'gte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -156,57 +135,41 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_1000, - MS_PER_DAY * 1000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: string) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => { - return get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']); - }); - - // should have started at the given time - expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 1700 - expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); - expect(intervals.length).toBeGreaterThan(1); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_1000, MS_PER_DAY * 1000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: string) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => { + return get(query, [ + 'bool', + 'must', + 'constant_score', + 'filter', + 'range', + '@timestamp', + ]); + }); + + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 1700 + expect(moment(last(intervals)?.lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); + expect(intervals.length).toBeGreaterThan(1); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - expect(hits).toEqual([]); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + expect(hits).toEqual([]); + } + ); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -214,16 +177,7 @@ describe('context predecessors', function () { }); it('should set the tiebreaker sort order to the opposite as the time field', function () { - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchPredecessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: 'asc', format: 'strict_date_optional_time' } }, @@ -248,32 +202,23 @@ describe('context predecessors', function () { }, } as unknown as DiscoverServices); - fetchPredecessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size = 10 - ) => { + fetchPredecessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size = 10) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.PREDECESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -287,23 +232,16 @@ describe('context predecessors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchPredecessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits: EsHitRecordList) => { - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); - }); + return fetchPredecessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits: EsHitRecordList) => { + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts index 656491f01f9cf..169d969753645 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.successors.test.ts @@ -8,11 +8,11 @@ import moment from 'moment'; import { get, last } from 'lodash'; -import { SortDirection } from 'src/plugins/data/common'; -import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; +import { IndexPattern, SortDirection } from 'src/plugins/data/common'; +import { createContextSearchSourceStub } from './_stubs'; import { setServices } from '../../../../kibana_services'; import { Query } from '../../../../../../data/public'; -import { fetchContextProvider, SurrDocType } from './context'; +import { fetchSurroundingDocs, SurrDocType } from './context'; import { DiscoverServices } from '../../../../build_services'; import { EsHitRecord, EsHitRecordList } from '../../../types'; @@ -29,9 +29,6 @@ interface Timestamp { describe('context successors', function () { let fetchSuccessors: ( - indexPatternId: string, - timeField: string, - sortDir: SortDirection, timeValIso: string, timeValNr: number, tieBreakerField: string, @@ -40,6 +37,12 @@ describe('context successors', function () { ) => Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockSearchSource: any; + const indexPattern = { + id: 'INDEX_PATTERN_ID', + timeFieldName: '@timestamp', + isTimeNanosBased: () => false, + popularizeField: () => {}, + } as unknown as IndexPattern; describe('function fetchSuccessors', function () { beforeEach(() => { @@ -55,30 +58,20 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, [] ); @@ -94,19 +87,12 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { @@ -118,33 +104,26 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 2990), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 6 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - expect( - intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) - ).toBe(true); - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have ended with a half-open interval - expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 6).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals) ?? {})).toEqual(['format', 'lte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + } + ); }); it('should perform multiple queries until the expected hit count is returned', function () { @@ -157,58 +136,33 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 1000), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 4 - ).then((hits) => { - const intervals: Timestamp[] = mockSearchSource.setField.args - .filter(([property]: [string]) => property === 'query') - .map(([, { query }]: [string, { query: Query }]) => - get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) - ); - - // should have started at the given time - expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); - // should have stopped before reaching MS_PER_DAY * 2200 - expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); - expect(intervals.length).toBeGreaterThan(1); - - expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 4).then( + (hits) => { + const intervals: Timestamp[] = mockSearchSource.setField.args + .filter(([property]: [string]) => property === 'query') + .map(([, { query }]: [string, { query: Query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 2200 + expect(moment(last(intervals)?.gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); + } + ); }); it('should return an empty array when no hits were found', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then((hits) => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then((hits) => { expect(hits).toEqual([]); }); }); it('should configure the SearchSource to not inherit from the implicit root', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3, - MS_PER_DAY * 3, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP_3, MS_PER_DAY * 3, '_doc', 0, 3).then(() => { const setParentSpy = mockSearchSource.setParent; expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); expect(setParentSpy.called).toBe(true); @@ -216,16 +170,7 @@ describe('context successors', function () { }); it('should set the tiebreaker sort order to the same as the time field', function () { - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP, - MS_PER_DAY, - '_doc', - 0, - 3 - ).then(() => { + return fetchSuccessors(ANCHOR_TIMESTAMP, MS_PER_DAY, '_doc', 0, 3).then(() => { expect( mockSearchSource.setField.calledWith('sort', [ { '@timestamp': { order: SortDirection.desc, format: 'strict_date_optional_time' } }, @@ -250,32 +195,23 @@ describe('context successors', function () { }, } as unknown as DiscoverServices); - fetchSuccessors = ( - indexPatternId, - timeField, - sortDir, - timeValIso, - timeValNr, - tieBreakerField, - tieBreakerValue, - size - ) => { + fetchSuccessors = (timeValIso, timeValNr, tieBreakerField, tieBreakerValue, size) => { const anchor = { _source: { - [timeField]: timeValIso, + [indexPattern.timeFieldName!]: timeValIso, }, sort: [timeValNr, tieBreakerValue], }; - return fetchContextProvider(createIndexPatternsStub(), true).fetchSurroundingDocs( + return fetchSurroundingDocs( SurrDocType.SUCCESSORS, - indexPatternId, + indexPattern, anchor as EsHitRecord, - timeField, tieBreakerField, - sortDir, + SortDirection.desc, size, - [] + [], + true ); }; }); @@ -289,23 +225,16 @@ describe('context successors', function () { mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), ]; - return fetchSuccessors( - 'INDEX_PATTERN_ID', - '@timestamp', - SortDirection.desc, - ANCHOR_TIMESTAMP_3000, - MS_PER_DAY * 3000, - '_doc', - 0, - 3 - ).then((hits) => { - expect(mockSearchSource.fetch.calledOnce).toBe(true); - expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); - const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); - const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); - expect(removeFieldsSpy.calledOnce).toBe(true); - expect(setFieldsSpy.calledOnce).toBe(true); - }); + return fetchSuccessors(ANCHOR_TIMESTAMP_3000, MS_PER_DAY * 3000, '_doc', 0, 3).then( + (hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + const setFieldsSpy = mockSearchSource.setField.withArgs('fields'); + const removeFieldsSpy = mockSearchSource.removeField.withArgs('fieldsFromSource'); + expect(removeFieldsSpy.calledOnce).toBe(true); + expect(setFieldsSpy.calledOnce).toBe(true); + } + ); }); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context.ts b/src/plugins/discover/public/application/apps/context/services/context.ts index f7fd65cda44c5..b76b5ac648c22 100644 --- a/src/plugins/discover/public/application/apps/context/services/context.ts +++ b/src/plugins/discover/public/application/apps/context/services/context.ts @@ -5,12 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { - Filter, - IndexPattern, - IndexPatternsContract, - ISearchSource, -} from 'src/plugins/data/public'; +import { Filter, IndexPattern, SearchSource } from 'src/plugins/data/public'; import { reverseSortDir, SortDirection } from './utils/sorting'; import { convertIsoToMillis, extractNanos } from './utils/date_conversion'; import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; @@ -30,92 +25,86 @@ const DAY_MILLIS = 24 * 60 * 60 * 1000; // look from 1 day up to 10000 days into the past and future const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map((days) => days * DAY_MILLIS); -function fetchContextProvider(indexPatterns: IndexPatternsContract, useNewFieldsApi?: boolean) { - return { - fetchSurroundingDocs, - }; - - /** - * Fetch successor or predecessor documents of a given anchor document - * - * @param {SurrDocType} type - `successors` or `predecessors` - * @param {string} indexPatternId - * @param {EsHitRecord} anchor - anchor record - * @param {string} timeField - name of the timefield, that's sorted on - * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field - * @param {SortDirection} sortDir - direction of sorting - * @param {number} size - number of records to retrieve - * @param {Filter[]} filters - to apply in the elastic query - * @returns {Promise} - */ - async function fetchSurroundingDocs( - type: SurrDocType, - indexPatternId: string, - anchor: EsHitRecord, - timeField: string, - tieBreakerField: string, - sortDir: SortDirection, - size: number, - filters: Filter[] - ): Promise { - if (typeof anchor !== 'object' || anchor === null || !size) { - return []; - } - const indexPattern = await indexPatterns.get(indexPatternId); - const { data } = getServices(); - const searchSource = data.search.searchSource.createEmpty(); - updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); - const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - - const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; - const timeValueMillis = - nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - - const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); - let documents: EsHitRecordList = []; - - for (const interval of intervals) { - const remainingSize = size - documents.length; - - if (remainingSize <= 0) { - break; - } +/** + * Fetch successor or predecessor documents of a given anchor document + * + * @param {SurrDocType} type - `successors` or `predecessors` + * @param {IndexPattern} indexPattern + * @param {EsHitRecord} anchor - anchor record + * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field + * @param {SortDirection} sortDir - direction of sorting + * @param {number} size - number of records to retrieve + * @param {Filter[]} filters - to apply in the elastic query + * @param {boolean} useNewFieldsApi + * @returns {Promise} + */ +export async function fetchSurroundingDocs( + type: SurrDocType, + indexPattern: IndexPattern, + anchor: EsHitRecord, + tieBreakerField: string, + sortDir: SortDirection, + size: number, + filters: Filter[], + useNewFieldsApi?: boolean +): Promise { + if (typeof anchor !== 'object' || anchor === null || !size) { + return []; + } + const { data } = getServices(); + const timeField = indexPattern.timeFieldName!; + const searchSource = data.search.searchSource.createEmpty() as SearchSource; + updateSearchSource(searchSource, indexPattern, filters, Boolean(useNewFieldsApi)); + const sortDirToApply = type === SurrDocType.SUCCESSORS ? sortDir : reverseSortDir(sortDir); - const searchAfter = getEsQuerySearchAfter( - type, - documents, - timeField, - anchor, - nanos, - useNewFieldsApi - ); + const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor.fields[timeField][0]) : ''; + const timeValueMillis = + nanos !== '' ? convertIsoToMillis(anchor.fields[timeField][0]) : anchor.sort[0]; - const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis as number, type, sortDir); + let documents: EsHitRecordList = []; - const hits = await fetchHitsInInterval( - searchSource, - timeField, - sort, - sortDirToApply, - interval, - searchAfter, - remainingSize, - nanos, - anchor._id - ); + for (const interval of intervals) { + const remainingSize = size - documents.length; - documents = - type === SurrDocType.SUCCESSORS - ? [...documents, ...hits] - : [...hits.slice().reverse(), ...documents]; + if (remainingSize <= 0) { + break; } - return documents; + const searchAfter = getEsQuerySearchAfter( + type, + documents, + timeField, + anchor, + nanos, + useNewFieldsApi + ); + + const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + + const hits = await fetchHitsInInterval( + searchSource, + timeField, + sort, + sortDirToApply, + interval, + searchAfter, + remainingSize, + nanos, + anchor._id + ); + + documents = + type === SurrDocType.SUCCESSORS + ? [...documents, ...hits] + : [...hits.slice().reverse(), ...documents]; } + + return documents; } export function updateSearchSource( - searchSource: ISearchSource, + searchSource: SearchSource, indexPattern: IndexPattern, filters: Filter[], useNewFieldsApi: boolean @@ -130,5 +119,3 @@ export function updateSearchSource( .setField('filter', filters) .setField('trackTotalHits', false); } - -export { fetchContextProvider }; diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts index 3e5acccff634e..3df8ab710729f 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.test.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.test.ts @@ -24,7 +24,6 @@ describe('Test Discover Context State', () => { history.push('/'); state = getState({ defaultSize: 4, - timeFieldName: 'time', history, uiSettings: { get: (key: string) => @@ -44,12 +43,6 @@ describe('Test Discover Context State', () => { ], "filters": Array [], "predecessorCount": 4, - "sort": Array [ - Array [ - "time", - "desc", - ], - ], "successorCount": 4, } `); @@ -62,41 +55,29 @@ describe('Test Discover Context State', () => { state.setAppState({ predecessorCount: 10 }); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,successorCount:4)"` ); }); test('getState -> url to appState syncing', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); }); test('getState -> url to appState syncing with return to a url without state', async () => { - history.push( - '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' - ); + history.push('/#?_a=(columns:!(_source),predecessorCount:1,successorCount:1)'); expect(state.appState.getState()).toMatchInlineSnapshot(` Object { "columns": Array [ "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -107,10 +88,6 @@ describe('Test Discover Context State', () => { "_source", ], "predecessorCount": 1, - "sort": Array [ - "time", - "desc", - ], "successorCount": 1, } `); @@ -183,7 +160,7 @@ describe('Test Discover Context State', () => { `); state.flushToUrl(); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,sort:!(!(time,desc)),successorCount:4)"` + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match_phrase:(extension:(query:jpg))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match_phrase:(extension:(query:png))))),predecessorCount:4,successorCount:4)"` ); }); }); diff --git a/src/plugins/discover/public/application/apps/context/services/context_state.ts b/src/plugins/discover/public/application/apps/context/services/context_state.ts index 582ca196e3484..87f7cf00bafcf 100644 --- a/src/plugins/discover/public/application/apps/context/services/context_state.ts +++ b/src/plugins/discover/public/application/apps/context/services/context_state.ts @@ -16,7 +16,7 @@ import { withNotifyOnErrors, ReduxLikeStateContainer, } from '../../../../../../kibana_utils/public'; -import { esFilters, FilterManager, Filter, SortDirection } from '../../../../../../data/public'; +import { esFilters, FilterManager, Filter } from '../../../../../../data/public'; import { handleSourceColumnState } from '../../../helpers/state_helpers'; export interface AppState { @@ -32,14 +32,16 @@ export interface AppState { * Number of records to be fetched before anchor records (newer records) */ predecessorCount: number; - /** - * Sorting of the records to be fetched, assumed to be a legacy parameter - */ - sort: string[][]; /** * Number of records to be fetched after the anchor records (older records) */ successorCount: number; + /** + * Array of the used sorting [[field,direction],...] + * this is actually not needed in Discover Context, there's no sorting + * but it's used in the DocTable component + */ + sort?: string[][]; } interface GlobalState { @@ -54,10 +56,6 @@ export interface GetStateParams { * Number of records to be fetched when 'Load' link/button is clicked */ defaultSize: number; - /** - * The timefield used for sorting - */ - timeFieldName: string; /** * Determins the use of long vs. short/hashed urls */ @@ -124,7 +122,6 @@ const APP_STATE_URL_KEY = '_a'; */ export function getState({ defaultSize, - timeFieldName, storeInSessionStorage = false, history, toasts, @@ -140,12 +137,7 @@ export function getState({ const globalStateContainer = createStateContainer(globalStateInitial); const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; - const appStateInitial = createInitialAppState( - defaultSize, - timeFieldName, - appStateFromUrl, - uiSettings - ); + const appStateInitial = createInitialAppState(defaultSize, appStateFromUrl, uiSettings); const appStateContainer = createStateContainer(appStateInitial); const { start, stop } = syncStates([ @@ -267,7 +259,6 @@ function getFilters(state: AppState | GlobalState): Filter[] { */ function createInitialAppState( defaultSize: number, - timeFieldName: string, urlState: AppState, uiSettings: IUiSettingsClient ): AppState { @@ -276,7 +267,6 @@ function createInitialAppState( filters: [], predecessorCount: defaultSize, successorCount: defaultSize, - sort: [[timeFieldName, SortDirection.desc]], }; if (typeof urlState !== 'object') { return defaultState; diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts index 5efd5e1195c5d..b3626f9c06f10 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.test.ts @@ -8,12 +8,9 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { setServices, getServices } from '../../../../kibana_services'; -import { SortDirection } from '../../../../../../data/public'; import { createFilterManagerMock } from '../../../../../../data/public/query/filter_manager/filter_manager.mock'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { indexPatternMock } from '../../../../__mocks__/index_pattern'; -import { indexPatternsMock } from '../../../../__mocks__/index_patterns'; import { FailureReason, LoadingStatus } from '../services/context_query_state'; import { ContextAppFetchProps, useContextAppFetch } from './use_context_app_fetch'; import { @@ -21,6 +18,9 @@ import { mockPredecessorHits, mockSuccessorHits, } from '../__mocks__/use_context_app_fetch'; +import { indexPatternWithTimefieldMock } from '../../../../__mocks__/index_pattern_with_timefield'; +import { createContextSearchSourceStub } from '../services/_stubs'; +import { IndexPattern } from '../../../../../../data_views/common'; const mockFilterManager = createFilterManagerMock(); @@ -28,20 +28,19 @@ jest.mock('../services/context', () => { const originalModule = jest.requireActual('../services/context'); return { ...originalModule, - fetchContextProvider: () => ({ - fetchSurroundingDocs: (type: string, indexPatternId: string) => { - if (!indexPatternId) { - throw new Error(); - } - return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; - }, - }), + + fetchSurroundingDocs: (type: string, indexPattern: IndexPattern) => { + if (!indexPattern || !indexPattern.id) { + throw new Error(); + } + return type === 'predecessors' ? mockPredecessorHits : mockSuccessorHits; + }, }; }); jest.mock('../services/anchor', () => ({ - fetchAnchorProvider: () => (indexPatternId: string) => { - if (!indexPatternId) { + fetchAnchor: (anchorId: string, indexPattern: IndexPattern) => { + if (!indexPattern.id || !anchorId) { throw new Error(); } return mockAnchorHit; @@ -50,16 +49,16 @@ jest.mock('../services/anchor', () => ({ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pattern-id') => { const dangerNotification = jest.fn(); + const mockSearchSource = createContextSearchSourceStub('timestamp'); setServices({ data: { search: { searchSource: { - createEmpty: jest.fn(), + createEmpty: jest.fn().mockImplementation(() => mockSearchSource), }, }, }, - indexPatterns: indexPatternsMock, toastNotifications: { addDanger: dangerNotification }, core: { notifications: { toasts: [] } }, history: () => {}, @@ -77,10 +76,8 @@ const initDefaults = (tieBreakerFields: string[], indexPatternId = 'the-index-pa dangerNotification, props: { anchorId: 'mock_anchor_id', - indexPatternId, - indexPattern: indexPatternMock, + indexPattern: { ...indexPatternWithTimefieldMock, id: indexPatternId }, appState: { - sort: [['order_date', SortDirection.desc]], predecessorCount: 2, successorCount: 2, }, diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx index fa6a761397335..ed3b4e8ed5b5a 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_fetch.tsx @@ -7,11 +7,10 @@ */ import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { fromPairs } from 'lodash'; import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../common'; import { DiscoverServices } from '../../../../build_services'; -import { fetchAnchorProvider } from '../services/anchor'; -import { fetchContextProvider, SurrDocType } from '../services/context'; +import { fetchAnchor } from '../services/anchor'; +import { fetchSurroundingDocs, SurrDocType } from '../services/context'; import { MarkdownSimple, toMountPoint } from '../../../../../../kibana_react/public'; import { IndexPattern, SortDirection } from '../../../../../../data/public'; import { @@ -30,7 +29,6 @@ const createError = (statusKey: string, reason: FailureReason, error?: Error) => export interface ContextAppFetchProps { anchorId: string; - indexPatternId: string; indexPattern: IndexPattern; appState: AppState; useNewFieldsApi: boolean; @@ -39,13 +37,12 @@ export interface ContextAppFetchProps { export function useContextAppFetch({ anchorId, - indexPatternId, indexPattern, appState, useNewFieldsApi, services, }: ContextAppFetchProps) { - const { uiSettings: config, data, indexPatterns, toastNotifications, filterManager } = services; + const { uiSettings: config, data, toastNotifications, filterManager } = services; const searchSource = useMemo(() => { return data.search.searchSource.createEmpty(); @@ -54,13 +51,6 @@ export function useContextAppFetch({ () => getFirstSortableField(indexPattern, config.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)), [config, indexPattern] ); - const fetchAnchor = useMemo(() => { - return fetchAnchorProvider(indexPatterns, searchSource, useNewFieldsApi); - }, [indexPatterns, searchSource, useNewFieldsApi]); - const { fetchSurroundingDocs } = useMemo( - () => fetchContextProvider(indexPatterns, useNewFieldsApi), - [indexPatterns, useNewFieldsApi] - ); const [fetchedState, setFetchedState] = useState( getInitialContextQueryState() @@ -71,8 +61,6 @@ export function useContextAppFetch({ }, []); const fetchAnchorRow = useCallback(async () => { - const { sort } = appState; - const [[, sortDir]] = sort; const errorTitle = i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { defaultMessage: 'Unable to load the anchor document', }); @@ -94,10 +82,11 @@ export function useContextAppFetch({ try { setState({ anchorStatus: { value: LoadingStatus.LOADING } }); - const anchor = await fetchAnchor(indexPatternId, anchorId, [ - fromPairs(sort), - { [tieBreakerField]: sortDir }, - ]); + const sort = [ + { [indexPattern.timeFieldName!]: SortDirection.desc }, + { [tieBreakerField]: SortDirection.desc }, + ]; + const anchor = await fetchAnchor(anchorId, indexPattern, searchSource, sort, useNewFieldsApi); setState({ anchor, anchorStatus: { value: LoadingStatus.LOADED } }); return anchor; } catch (error) { @@ -108,20 +97,18 @@ export function useContextAppFetch({ }); } }, [ - appState, tieBreakerField, setState, toastNotifications, - fetchAnchor, - indexPatternId, + indexPattern, anchorId, + searchSource, + useNewFieldsApi, ]); const fetchSurroundingRows = useCallback( async (type: SurrDocType, fetchedAnchor?: EsHitRecord) => { const filters = filterManager.getFilters(); - const { sort } = appState; - const [[sortField, sortDir]] = sort; const count = type === SurrDocType.PREDECESSORS ? appState.predecessorCount : appState.successorCount; @@ -135,13 +122,13 @@ export function useContextAppFetch({ setState({ [statusKey]: { value: LoadingStatus.LOADING } }); const rows = await fetchSurroundingDocs( type, - indexPatternId, + indexPattern, anchor as EsHitRecord, - sortField, tieBreakerField, - sortDir as SortDirection, + SortDirection.desc, count, - filters + filters, + useNewFieldsApi ); setState({ [type]: rows, [statusKey]: { value: LoadingStatus.LOADED } }); } catch (error) { @@ -158,9 +145,9 @@ export function useContextAppFetch({ fetchedState.anchor, tieBreakerField, setState, - fetchSurroundingDocs, - indexPatternId, + indexPattern, toastNotifications, + useNewFieldsApi, ] ); diff --git a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts index 3e968b5dfb82e..56701f17c7a63 100644 --- a/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts +++ b/src/plugins/discover/public/application/apps/context/utils/use_context_app_state.ts @@ -9,29 +9,21 @@ import { useEffect, useMemo, useState } from 'react'; import { cloneDeep } from 'lodash'; import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../../../common'; -import { IndexPattern } from '../../../../../../data/public'; import { DiscoverServices } from '../../../../build_services'; import { AppState, getState } from '../services/context_state'; -export function useContextAppState({ - indexPattern, - services, -}: { - indexPattern: IndexPattern; - services: DiscoverServices; -}) { +export function useContextAppState({ services }: { services: DiscoverServices }) { const { uiSettings: config, history, core, filterManager } = services; const stateContainer = useMemo(() => { return getState({ defaultSize: parseInt(config.get(CONTEXT_DEFAULT_SIZE_SETTING), 10), - timeFieldName: indexPattern.timeFieldName!, storeInSessionStorage: config.get('state:storeInSessionStorage'), history: history(), toasts: core.notifications.toasts, uiSettings: config, }); - }, [config, history, indexPattern, core.notifications.toasts]); + }, [config, history, core.notifications.toasts]); const [appState, setState] = useState(stateContainer.appState.getState()); From ea296ce9678c6ec60b5ba59bf129f5aa37ce7296 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 20:07:08 -0400 Subject: [PATCH 33/54] Clean angular from moved code, global state and legacy shims (#115420) (#115461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ester Martí Vilaseca --- .../public/alerts/alert_form.test.tsx | 18 +- .../public/angular/angular_i18n/directive.ts | 103 ------ .../public/angular/angular_i18n/filter.ts | 19 - .../public/angular/angular_i18n/index.ts | 15 - .../public/angular/angular_i18n/provider.ts | 25 -- .../helpers/format_angular_http_error.ts | 43 --- .../public/angular/top_nav/angular_config.tsx | 349 ------------------ .../public/angular/top_nav/index.ts | 10 - .../public/angular/top_nav/kbn_top_nav.d.ts | 16 - .../public/angular/top_nav/kbn_top_nav.js | 119 ------ .../contexts/global_state_context.tsx | 25 +- .../plugins/monitoring/public/legacy_shims.ts | 25 +- x-pack/plugins/monitoring/public/url_state.ts | 34 -- 13 files changed, 18 insertions(+), 783 deletions(-) delete mode 100644 x-pack/plugins/monitoring/public/angular/angular_i18n/directive.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/angular_i18n/filter.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/angular_i18n/index.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/angular_i18n/provider.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/helpers/format_angular_http_error.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/top_nav/angular_config.tsx delete mode 100644 x-pack/plugins/monitoring/public/angular/top_nav/index.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.d.ts delete mode 100644 x-pack/plugins/monitoring/public/angular/top_nav/kbn_top_nav.js 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 8752a4118fb6a..1a44beab260cb 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 1aaff99a6a5c1..0000000000000 --- 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 e4e553fa47b6f..0000000000000 --- 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 8915c96e59be0..0000000000000 --- 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 b1da1bad6e399..0000000000000 --- 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 abdcf157a3c86..0000000000000 --- 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 9c2e931d24a94..0000000000000 --- 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 b3501e4cbad1f..0000000000000 --- 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 0cff77241bb9c..0000000000000 --- 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 6edcca6aa714a..0000000000000 --- 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 e6638b4c4fede..cc8619dbc7ad2 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 72d50aac1dbb8..48484421839bd 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 25086411c65a3..8f89df732b800 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 }; From be65efcc473825fe64c6d86b2c7401050a65cffe Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 18 Oct 2021 20:19:53 -0400 Subject: [PATCH 34/54] [Fleet, App search] Add App Search ingestion methods to the unified integrations view (#115433) (#115463) * Add a new category for Web crawler * Add App Search integrations * Fix isBeta flag for Web Crawler It's already GA Co-authored-by: Vadim Yakhin --- .../custom_integrations/common/index.ts | 1 + .../enterprise_search/server/integrations.ts | 63 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 944ac6ba3e6ee..98148bb22c816 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/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index 48909261243e8..eee5cdc3aaec3 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, + }); }; From 5840c438a816b54b483b79d4184c23b3148ffcf4 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 19 Oct 2021 02:41:31 +0200 Subject: [PATCH 35/54] [ML] APM Correlations: Get trace samples tab overall distribution via APM endpoint. (#114615) (#115466) This creates an APM API endpoint that fetches data for the latency distribution chart in the trace samples tab on the transactions page. Previously, this data was fetched via the custom Kibana search strategies used for APM Correlations which causes issues in load balancing setups. --- .../failed_transactions_correlations/types.ts | 13 +- .../latency_correlations/types.ts | 12 +- .../apm/common/search_strategies/types.ts | 12 +- .../latency_correlations.test.tsx | 5 +- .../distribution/index.test.tsx | 59 +++------ .../distribution/index.tsx | 79 +++++++++--- .../apm/public/hooks/use_search_strategy.ts | 10 +- .../get_overall_latency_distribution.ts | 121 ++++++++++++++++++ .../latency/get_percentile_threshold_value.ts | 53 ++++++++ .../plugins/apm/server/lib/latency/types.ts | 23 ++++ ...ransactions_correlations_search_service.ts | 31 ++--- .../failed_transactions_correlations/index.ts | 6 +- .../latency_correlations/index.ts | 6 +- .../latency_correlations_search_service.ts | 31 ++--- .../field_stats/get_field_stats.test.ts | 4 +- .../queries/get_query_with_params.test.ts | 12 +- .../queries/get_query_with_params.ts | 17 +-- .../queries/get_request_base.test.ts | 4 + .../queries/query_correlation.test.ts | 4 +- .../queries/query_field_candidates.test.ts | 4 +- .../queries/query_field_value_pairs.test.ts | 4 +- .../queries/query_fractions.test.ts | 4 +- .../queries/query_histogram.test.ts | 4 +- .../query_histogram_range_steps.test.ts | 4 +- .../queries/query_histogram_range_steps.ts | 6 +- .../query_histograms_generator.test.ts | 4 +- .../queries/query_percentiles.test.ts | 4 +- .../queries/query_ranges.test.ts | 4 +- .../search_strategy_provider.test.ts | 4 +- .../search_strategy_provider.ts | 104 +++++++++++---- .../get_global_apm_server_route_repository.ts | 2 + .../apm/server/routes/latency_distribution.ts | 63 +++++++++ .../tests/correlations/failed_transactions.ts | 4 +- .../tests/correlations/latency.ts | 23 ++-- .../test/apm_api_integration/tests/index.ts | 4 + .../latency_overall_distribution.ts | 65 ++++++++++ 36 files changed, 590 insertions(+), 219 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/latency/get_overall_latency_distribution.ts create mode 100644 x-pack/plugins/apm/server/lib/latency/get_percentile_threshold_value.ts create mode 100644 x-pack/plugins/apm/server/lib/latency/types.ts create mode 100644 x-pack/plugins/apm/server/routes/latency_distribution.ts create mode 100644 x-pack/test/apm_api_integration/tests/transactions/latency_overall_distribution.ts 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 266d7246c35d4..28ce2ff24b961 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 2eb2b37159459..ea74175a3dacb 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 d7c6eab1f07c1..ff925f70fc9b0 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 9956452c565b3..918f94e64ef09 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 bd0ff4c87c3be..0e9639de4aa74 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 ca8d28b106f84..275eddb68ae00 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 0000000000000..39470869488c3 --- /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 0000000000000..0d417a370e0b6 --- /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 0000000000000..8dad1a39bd159 --- /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 af5e535abdc3f..efc28ce98e5e0 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 ec91165cb481b..4763cd994d309 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 073bb122896ff..040aa5a7e424e 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 4862f7dd1de1a..f170818d018d4 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 deb89ace47c5d..d3cee1c4ca596 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 c77b4df78f865..9d0441e513198 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 f00c89503f103..31a98b0a6bb18 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 fd5f52207d4c5..eb771e1e1aaf4 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 fc2dacce61a73..40fcc17444492 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 6e0521ac1a008..bae42666e6db0 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 9ffbf6b2ce18d..ab7a0b4e02072 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 daf6b368c78b1..9c704ef7b489a 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 7ecb1d2d8a333..7cc6106f671a7 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 ffc86c7ef6c32..41a2fa9a5039e 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 790919d193028..439bb9e4b9cd6 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 375e32b1472c6..00e8c26497eb2 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 ce86ffd9654e6..57e3e6cadb9bc 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 e210eb7d41e78..7d67e80ae3398 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 8a9d04df32036..034bd2a60ad19 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 cec10294460b0..8035e9e4d97ca 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 472e46fecfa10..3fa6152d953f3 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 0000000000000..ea921a7f4838d --- /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/test/apm_api_integration/tests/correlations/failed_transactions.ts b/x-pack/test/apm_api_integration/tests/correlations/failed_transactions.ts index 6e2025a7fa2ca..3388d5b4aa379 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 99aee770c625d..75a4edd447c70 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/index.ts b/x-pack/test/apm_api_integration/tests/index.ts index 09f4e2596ea46..f68a49658f2ee 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')); }); 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 0000000000000..c915ac8911e37 --- /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); + }); + } + ); +} From ae659e5c79c4250a08a6aa328320c9a0f209e31f Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 18 Oct 2021 21:08:56 -0400 Subject: [PATCH 36/54] skip flaky suite (#115488) --- .../test/security_solution_endpoint_api_int/apis/metadata.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 35fe0cdd6da25..2dcf36cc42ae2 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -24,7 +24,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - describe('test metadata api', () => { + // Failing: See https://github.com/elastic/kibana/issues/115488 + describe.skip('test metadata api', () => { // TODO add this after endpoint package changes are merged and in snapshot // describe('with .metrics-endpoint.metadata_united_default index', () => { // }); From 28db91718b5b277c9df16248cb8942a71814b695 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 00:28:59 -0400 Subject: [PATCH 37/54] [APM] APM-Fleet integration version check & upgrade message (#115297) (#115484) Co-authored-by: Oliver Gupte --- x-pack/plugins/apm/common/fleet.ts | 2 + .../components/app/Settings/schema/index.tsx | 4 +- .../schema/migrated/card_footer_content.tsx | 47 +++++++++++++ .../migrated/successful_migration_card.tsx | 30 ++++++++ .../migrated/upgrade_available_card.tsx | 51 ++++++++++++++ .../app/Settings/schema/schema_overview.tsx | 68 +++++-------------- .../public/components/shared/Links/kibana.ts | 11 +++ .../get_apm_package_policy_definition.ts | 7 +- x-pack/plugins/apm/server/routes/fleet.ts | 6 +- 9 files changed, 170 insertions(+), 56 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/card_footer_content.tsx create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx create mode 100644 x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx diff --git a/x-pack/plugins/apm/common/fleet.ts b/x-pack/plugins/apm/common/fleet.ts index 618cd20d66159..97551cc16b4be 100644 --- a/x-pack/plugins/apm/common/fleet.ts +++ b/x-pack/plugins/apm/common/fleet.ts @@ -6,3 +6,5 @@ */ export const POLICY_ELASTIC_AGENT_ON_CLOUD = 'policy-elastic-agent-on-cloud'; + +export const SUPPORTED_APM_PACKAGE_VERSION = '7.16.0'; diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx index ac32e22fa3ded..b13046d34be94 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/index.tsx @@ -54,7 +54,8 @@ export function Schema() { const isLoading = status !== FETCH_STATUS.SUCCESS; const cloudApmMigrationEnabled = !!data.cloud_apm_migration_enabled; const hasCloudAgentPolicy = !!data.has_cloud_agent_policy; - const hasCloudApmPackagePolicy = !!data.has_cloud_apm_package_policy; + const cloudApmPackagePolicy = data.cloud_apm_package_policy; + const hasCloudApmPackagePolicy = !!cloudApmPackagePolicy; const hasRequiredRole = !!data.has_required_role; function updateLocalStorage(newStatus: FETCH_STATUS) { @@ -90,6 +91,7 @@ export function Schema() { cloudApmMigrationEnabled={cloudApmMigrationEnabled} hasCloudAgentPolicy={hasCloudAgentPolicy} hasRequiredRole={hasRequiredRole} + cloudApmPackagePolicy={cloudApmPackagePolicy} /> {isSwitchActive && ( + + {i18n.translate( + 'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText', + { defaultMessage: 'View the APM integration in Fleet' } + )} + + + +

+ + {i18n.translate( + 'xpack.apm.settings.schema.success.returnText.serviceInventoryLink', + { defaultMessage: 'Service inventory' } + )} + + ), + }} + /> +

+
+
+ ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx new file mode 100644 index 0000000000000..839479fbbf652 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/successful_migration_card.tsx @@ -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. + */ + +import { EuiCard, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { CardFooterContent } from './card_footer_content'; + +export function SuccessfulMigrationCard() { + return ( + } + title={i18n.translate('xpack.apm.settings.schema.success.title', { + defaultMessage: 'Elastic Agent successfully setup!', + })} + description={i18n.translate( + 'xpack.apm.settings.schema.success.description', + { + defaultMessage: + 'Your APM integration is now setup and ready to receive data from your currently instrumented agents. Feel free to review the policies applied to your integtration.', + } + )} + footer={} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.tsx new file mode 100644 index 0000000000000..8c10236335961 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/migrated/upgrade_available_card.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 { EuiCard, EuiIcon, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { useUpgradeApmPackagePolicyHref } from '../../../../shared/Links/kibana'; +import { CardFooterContent } from './card_footer_content'; + +export function UpgradeAvailableCard({ + apmPackagePolicyId, +}: { + apmPackagePolicyId: string | undefined; +}) { + const upgradeApmPackagePolicyHref = + useUpgradeApmPackagePolicyHref(apmPackagePolicyId); + + return ( + } + title={i18n.translate( + 'xpack.apm.settings.schema.upgradeAvailable.title', + { + defaultMessage: 'APM integration upgrade available!', + } + )} + description={ + + {i18n.translate( + 'xpack.apm.settings.schema.upgradeAvailable.upgradePackagePolicyLink', + { defaultMessage: 'Upgrade your APM integration' } + )} +
+ ), + }} + /> + } + footer={} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx index 0031c102e8ae5..cead6cd8a6fb4 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/schema/schema_overview.tsx @@ -19,11 +19,14 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { APMLink } from '../../../shared/Links/apm/APMLink'; +import semverLt from 'semver/functions/lt'; +import { SUPPORTED_APM_PACKAGE_VERSION } from '../../../../../common/fleet'; +import { PackagePolicy } from '../../../../../../fleet/common/types'; import { ElasticDocsLink } from '../../../shared/Links/ElasticDocsLink'; -import { useFleetCloudAgentPolicyHref } from '../../../shared/Links/kibana'; import rocketLaunchGraphic from './blog-rocket-720x420.png'; import { MigrationInProgressPanel } from './migration_in_progress_panel'; +import { UpgradeAvailableCard } from './migrated/upgrade_available_card'; +import { SuccessfulMigrationCard } from './migrated/successful_migration_card'; interface Props { onSwitch: () => void; @@ -34,6 +37,7 @@ interface Props { cloudApmMigrationEnabled: boolean; hasCloudAgentPolicy: boolean; hasRequiredRole: boolean; + cloudApmPackagePolicy: PackagePolicy | undefined; } export function SchemaOverview({ onSwitch, @@ -44,10 +48,13 @@ export function SchemaOverview({ cloudApmMigrationEnabled, hasCloudAgentPolicy, hasRequiredRole, + cloudApmPackagePolicy, }: Props) { - const fleetCloudAgentPolicyHref = useFleetCloudAgentPolicyHref(); const isDisabled = !cloudApmMigrationEnabled || !hasCloudAgentPolicy || !hasRequiredRole; + const packageVersion = cloudApmPackagePolicy?.package?.version; + const isUpgradeAvailable = + packageVersion && semverLt(packageVersion, SUPPORTED_APM_PACKAGE_VERSION); if (isLoading) { return ( @@ -76,54 +83,13 @@ export function SchemaOverview({ - - } - title={i18n.translate('xpack.apm.settings.schema.success.title', { - defaultMessage: 'Elastic Agent successfully setup!', - })} - description={i18n.translate( - 'xpack.apm.settings.schema.success.description', - { - defaultMessage: - 'Your APM integration is now setup and ready to receive data from your currently instrumented agents. Feel free to review the policies applied to your integtration.', - } - )} - footer={ -
- - {i18n.translate( - 'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText', - { defaultMessage: 'View the APM integration in Fleet' } - )} - - - -

- - {i18n.translate( - 'xpack.apm.settings.schema.success.returnText.serviceInventoryLink', - { defaultMessage: 'Service inventory' } - )} - - ), - }} - /> -

-
-
- } - /> + {isUpgradeAvailable ? ( + + ) : ( + + )}
diff --git a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts index bfb7cf849f567..c0bdf3a98aa31 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/kibana.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/kibana.ts @@ -26,3 +26,14 @@ export function useFleetCloudAgentPolicyHref() { } = useApmPluginContext(); return basePath.prepend('/app/fleet#/policies/policy-elastic-agent-on-cloud'); } + +export function useUpgradeApmPackagePolicyHref(packagePolicyId = '') { + const { + core: { + http: { basePath }, + }, + } = useApmPluginContext(); + return basePath.prepend( + `/app/fleet/policies/policy-elastic-agent-on-cloud/upgrade-package-policy/${packagePolicyId}?from=integrations-policy-list` + ); +} diff --git a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts index 64b071b67d2bd..98b6a6489c47b 100644 --- a/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts +++ b/x-pack/plugins/apm/server/lib/fleet/get_apm_package_policy_definition.ts @@ -5,7 +5,10 @@ * 2.0. */ -import { POLICY_ELASTIC_AGENT_ON_CLOUD } from '../../../common/fleet'; +import { + POLICY_ELASTIC_AGENT_ON_CLOUD, + SUPPORTED_APM_PACKAGE_VERSION, +} from '../../../common/fleet'; import { APMPluginSetupDependencies } from '../../types'; import { APM_PACKAGE_NAME } from './get_cloud_apm_package_policy'; @@ -36,7 +39,7 @@ export function getApmPackagePolicyDefinition( ], package: { name: APM_PACKAGE_NAME, - version: '0.4.0', + version: SUPPORTED_APM_PACKAGE_VERSION, title: 'Elastic APM', }, }; diff --git a/x-pack/plugins/apm/server/routes/fleet.ts b/x-pack/plugins/apm/server/routes/fleet.ts index 2884c08ceb9a1..e18aefcd6e0d8 100644 --- a/x-pack/plugins/apm/server/routes/fleet.ts +++ b/x-pack/plugins/apm/server/routes/fleet.ts @@ -92,7 +92,7 @@ const fleetAgentsRoute = createApmServerRoute({ }); const saveApmServerSchemaRoute = createApmServerRoute({ - endpoint: 'POST /internal/apm/fleet/apm_server_schema', + endpoint: 'POST /api/apm/fleet/apm_server_schema', options: { tags: ['access:apm', 'access:apm_write'] }, params: t.type({ body: t.type({ @@ -143,11 +143,13 @@ const getMigrationCheckRoute = createApmServerRoute({ fleetPluginStart, }) : undefined; + const apmPackagePolicy = getApmPackagePolicy(cloudAgentPolicy); return { has_cloud_agent_policy: !!cloudAgentPolicy, - has_cloud_apm_package_policy: !!getApmPackagePolicy(cloudAgentPolicy), + has_cloud_apm_package_policy: !!apmPackagePolicy, cloud_apm_migration_enabled: cloudApmMigrationEnabled, has_required_role: hasRequiredRole, + cloud_apm_package_policy: apmPackagePolicy, }; }, }); From ad9f36e61bb7d735f60dfe4a988c155baeb4427e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 01:18:06 -0400 Subject: [PATCH 38/54] [Uptime] Fix unhandled promise rejection failure (#114883) (#115491) * Fix unhandled promise rejection failure. * Mock monaco to avoid editor-related errors failing test. * Update assertion. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Justin Kambic --- .../components/fleet_package/custom_fields.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx index 26ee26cc8ed7f..62c6f5598adb4 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx @@ -313,11 +313,11 @@ describe.skip('', () => { // resolve errors fireEvent.click(monitorType); - waitFor(() => { - expect(getByText('http')).toBeInTheDocument(); - expect(getByText('tcp')).toBeInTheDocument(); - expect(getByText('icmp')).toBeInTheDocument(); - expect(queryByText('browser')).not.toBeInTheDocument(); + await waitFor(() => { + expect(getByText('HTTP')).toBeInTheDocument(); + expect(getByText('TCP')).toBeInTheDocument(); + expect(getByText('ICMP')).toBeInTheDocument(); + expect(queryByText('Browser')).not.toBeInTheDocument(); }); }); }); From f9a08d4495a1b91b13b33ae2063cd4493deef859 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 02:01:45 -0400 Subject: [PATCH 39/54] [Security Solution] Skip flakey test Configures a new connector.Cases connectors Configures a new connector (#115440) (#115480) Co-authored-by: Kevin Logan <56395104+kevinlog@users.noreply.github.com> --- .../cypress/integration/cases/connectors.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 287d86c6fba9e..69b623de0b43c 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', From 3e17aff5476fb3466a564e7921f8067af347c3b5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 03:33:51 -0400 Subject: [PATCH 40/54] [APM] Add readme for @elastic/apm-generator (#115368) (#115506) Co-authored-by: Dario Gieselaar --- packages/elastic-apm-generator/README.md | 93 ++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/elastic-apm-generator/README.md b/packages/elastic-apm-generator/README.md index e69de29bb2d1d..e43187a8155d3 100644 --- a/packages/elastic-apm-generator/README.md +++ b/packages/elastic-apm-generator/README.md @@ -0,0 +1,93 @@ +# @elastic/apm-generator + +`@elastic/apm-generator` is an experimental tool to generate synthetic APM data. It is intended to be used for development and testing of the Elastic APM app in Kibana. + +At a high-level, the module works by modeling APM events/metricsets with [a fluent API](https://en.wikipedia.org/wiki/Fluent_interface). The models can then be serialized and converted to Elasticsearch documents. In the future we might support APM Server as an output as well. + +## Usage + +This section assumes that you've installed Kibana's dependencies by running `yarn kbn bootstrap` in the repository's root folder. + +This library can currently be used in two ways: + +- Imported as a Node.js module, for instance to be used in Kibana's functional test suite. +- With a command line interface, to index data based on some example scenarios. + +### Using the Node.js module + +#### Concepts + +- `Service`: a logical grouping for a monitored service. A `Service` object contains fields like `service.name`, `service.environment` and `agent.name`. +- `Instance`: a single instance of a monitored service. E.g., the workload for a monitored service might be spread across multiple containers. An `Instance` object contains fields like `service.node.name` and `container.id`. +- `Timerange`: an object that will return an array of timestamps based on an interval and a rate. These timestamps can be used to generate events/metricsets. +- `Transaction`, `Span`, `APMError` and `Metricset`: events/metricsets that occur on an instance. For more background, see the [explanation of the APM data model](https://www.elastic.co/guide/en/apm/get-started/7.15/apm-data-model.html) + + +#### Example + +```ts +import { service, timerange, toElasticsearchOutput } from '@elastic/apm-generator'; + +const instance = service('synth-go', 'production', 'go') + .instance('instance-a'); + +const from = new Date('2021-01-01T12:00:00.000Z').getTime(); +const to = new Date('2021-01-01T12:00:00.000Z').getTime() - 1; + +const traceEvents = timerange(from, to) + .interval('1m') + .rate(10) + .flatMap(timestamp => instance.transaction('GET /api/product/list') + .timestamp(timestamp) + .duration(1000) + .success() + .children( + instance.span('GET apm-*/_search', 'db', 'elasticsearch') + .timestamp(timestamp + 50) + .duration(900) + .destination('elasticsearch') + .success() + ).serialize() + ); + +const metricsets = timerange(from, to) + .interval('30s') + .rate(1) + .flatMap(timestamp => instance.appMetrics({ + 'system.memory.actual.free': 800, + 'system.memory.total': 1000, + 'system.cpu.total.norm.pct': 0.6, + 'system.process.cpu.total.norm.pct': 0.7, + }).timestamp(timestamp) + .serialize() + ); + +const esEvents = toElasticsearchOutput(traceEvents.concat(metricsets)); +``` + +#### Generating metricsets + +`@elastic/apm-generator` can also automatically generate transaction metrics, span destination metrics and transaction breakdown metrics based on the generated trace events. If we expand on the previous example: + +```ts +import { getTransactionMetrics, getSpanDestinationMetrics, getBreakdownMetrics } from '@elastic/apm-generator'; + +const esEvents = toElasticsearchOutput([ + ...traceEvents, + ...getTransactionMetrics(traceEvents), + ...getSpanDestinationMetrics(traceEvents), + ...getBreakdownMetrics(traceEvents) +]); +``` + +### CLI + +Via the CLI, you can upload examples. The supported examples are listed in `src/lib/es.ts`. A `--target` option that specifies the Elasticsearch URL should be defined when running the `example` command. Here's an example: + +`$ node packages/elastic-apm-generator/src/scripts/es.js example simple-trace --target=http://admin:changeme@localhost:9200` + +The following options are supported: +- `to`: the end of the time range, in ISO format. By default, the current time will be used. +- `from`: the start of the time range, in ISO format. By default, `to` minus 15 minutes will be used. +- `apm-server-version`: the version used in the index names bootstrapped by APM Server, e.g. `7.16.0`. __If these indices do not exist, the script will exit with an error. It will not bootstrap the indices itself.__ + From 374398c53bc710f5b5e4d072122ecf0e80beccd3 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 04:36:06 -0400 Subject: [PATCH 41/54] [Security Solution] fix endpoint list agent status logic (#115286) (#115498) Co-authored-by: Joey F. Poon --- .../server/endpoint/routes/metadata/handlers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts index 027107bcf1a59..e98cdc4f11404 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/handlers.ts @@ -48,6 +48,7 @@ import { } from './support/query_strategies'; import { NotFoundError } from '../../errors'; import { EndpointHostUnEnrolledError } from '../../services/metadata'; +import { getAgentStatus } from '../../../../../fleet/common/services/agent_status'; export interface MetadataRequestContext { esClient?: IScopedClusterClient; @@ -522,10 +523,11 @@ async function queryUnitedIndex( const agentPolicy = agentPoliciesMap[agent.policy_id!]; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const endpointPolicy = endpointPoliciesMap[agent.policy_id!]; + const fleetAgentStatus = getAgentStatus(agent as Agent); + return { metadata, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - host_status: fleetAgentStatusToEndpointHostStatus(agent.last_checkin_status!), + host_status: fleetAgentStatusToEndpointHostStatus(fleetAgentStatus), policy_info: { agent: { applied: { From d92578780150fc0296e1b25a7e1e5b36d07c9464 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 04:46:57 -0400 Subject: [PATCH 42/54] [Security Solution][Endpoint]Activity Log API/UX changes (#114905) (#115492) * rename legacy actions/responses fixes elastic/security-team/issues/1702 * use correct name for responses index refs elastic/kibana/pull/113621 * extract helper method to utils * append endpoint responses docs to activity log * Show completed responses on activity log fixes elastic/security-team/issues/1703 * remove width restriction on date picker * add a simple test to verify endpoint responses fixes elastic/security-team/issues/1702 * find unique action_ids from `.fleet-actions` and `.logs-endpoint.actions-default` indices fixes elastic/security-team/issues/1702 * do not filter out endpoint only actions/responses that did not make it to Fleet review comments * use a constant to manage various doc types review comments * refactor `getActivityLog` Simplify `getActivityLog` so it is easier to reason with. review comments * skip this for now will mock this better in a new PR * improve types * display endpoint actions similar to fleet actions, but with success icon color * Correctly do mocks for tests * Include only errored endpoint actions, remove successful duplicates fixes elastic/security-team/issues/1703 * Update tests to use non duplicate action_ids review comments fixes elastic/security-team/issues/1703 * show correct action title review fixes * statusCode constant review change * rename review changes * Update translations.ts refs https://github.com/elastic/kibana/pull/114905/commits/74a8340b5eb2e31faba67a4fbe656f74fe52d0a2 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Ashokaditya --- .../common/endpoint/constants.ts | 4 +- .../common/endpoint/types/actions.ts | 33 ++- .../activity_log_date_range_picker/index.tsx | 1 - .../view/details/components/log_entry.tsx | 101 ++++++- .../components/log_entry_timeline_icon.tsx | 21 +- .../view/details/endpoints.stories.tsx | 14 +- .../pages/endpoint_hosts/view/index.test.tsx | 95 +++++-- .../pages/endpoint_hosts/view/translations.ts | 36 +++ .../endpoint/routes/actions/audit_log.test.ts | 213 ++++++++++++-- .../endpoint/routes/actions/isolation.ts | 29 +- .../server/endpoint/routes/actions/mocks.ts | 32 +++ .../server/endpoint/services/actions.ts | 146 ++++------ .../endpoint/utils/audit_log_helpers.ts | 266 ++++++++++++++++++ .../server/endpoint/utils/index.ts | 2 + .../endpoint/utils/yes_no_data_stream.test.ts | 100 +++++++ .../endpoint/utils/yes_no_data_stream.ts | 59 ++++ 16 files changed, 949 insertions(+), 203 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 6e9123da2dd9b..178a2b68a4aab 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -10,7 +10,7 @@ export const ENDPOINT_ACTIONS_DS = '.logs-endpoint.actions'; export const ENDPOINT_ACTIONS_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; export const ENDPOINT_ACTION_RESPONSES_DS = '.logs-endpoint.action.responses'; -export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTIONS_DS}-default`; +export const ENDPOINT_ACTION_RESPONSES_INDEX = `${ENDPOINT_ACTION_RESPONSES_DS}-default`; export const eventsIndexPattern = 'logs-endpoint.events.*'; export const alertsIndexPattern = 'logs-endpoint.alerts-*'; @@ -60,3 +60,5 @@ export const UNISOLATE_HOST_ROUTE = `${BASE_ENDPOINT_ROUTE}/unisolate`; /** Endpoint Actions Log Routes */ export const ENDPOINT_ACTION_LOG_ROUTE = `/api/endpoint/action_log/{agent_id}`; export const ACTION_STATUS_ROUTE = `/api/endpoint/action_status`; + +export const failedFleetActionErrorCode = '424'; diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts index bc46ca2f5b451..fb29297eb5929 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts @@ -10,6 +10,13 @@ import { ActionStatusRequestSchema, HostIsolationRequestSchema } from '../schema export type ISOLATION_ACTIONS = 'isolate' | 'unisolate'; +export const ActivityLogItemTypes = { + ACTION: 'action' as const, + RESPONSE: 'response' as const, + FLEET_ACTION: 'fleetAction' as const, + FLEET_RESPONSE: 'fleetResponse' as const, +}; + interface EcsError { code?: string; id?: string; @@ -87,8 +94,24 @@ export interface EndpointActionResponse { action_data: EndpointActionData; } +export interface EndpointActivityLogAction { + type: typeof ActivityLogItemTypes.ACTION; + item: { + id: string; + data: LogsEndpointAction; + }; +} + +export interface EndpointActivityLogActionResponse { + type: typeof ActivityLogItemTypes.RESPONSE; + item: { + id: string; + data: LogsEndpointActionResponse; + }; +} + export interface ActivityLogAction { - type: 'action'; + type: typeof ActivityLogItemTypes.FLEET_ACTION; item: { // document _id id: string; @@ -97,7 +120,7 @@ export interface ActivityLogAction { }; } export interface ActivityLogActionResponse { - type: 'response'; + type: typeof ActivityLogItemTypes.FLEET_RESPONSE; item: { // document id id: string; @@ -105,7 +128,11 @@ export interface ActivityLogActionResponse { data: EndpointActionResponse; }; } -export type ActivityLogEntry = ActivityLogAction | ActivityLogActionResponse; +export type ActivityLogEntry = + | ActivityLogAction + | ActivityLogActionResponse + | EndpointActivityLogAction + | EndpointActivityLogActionResponse; export interface ActivityLog { page: number; pageSize: number; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx index 05887d82cacad..a57fa8d8e4ce5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/activity_log_date_range_picker/index.tsx @@ -32,7 +32,6 @@ interface Range { const DatePickerWrapper = styled.div` width: ${(props) => props.theme.eui.fractions.single.percentage}; - max-width: 350px; `; const StickyFlexItem = styled(EuiFlexItem)` background: ${(props) => `${props.theme.eui.euiHeaderBackgroundColor}`}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx index bbe0a6f3afcd1..79af2ecb354fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry.tsx @@ -9,24 +9,34 @@ import React, { memo, useMemo } from 'react'; import styled from 'styled-components'; import { EuiComment, EuiText, EuiAvatarProps, EuiCommentProps, IconType } from '@elastic/eui'; -import { Immutable, ActivityLogEntry } from '../../../../../../../common/endpoint/types'; +import { + Immutable, + ActivityLogEntry, + ActivityLogItemTypes, +} from '../../../../../../../common/endpoint/types'; import { FormattedRelativePreferenceDate } from '../../../../../../common/components/formatted_date'; import { LogEntryTimelineIcon } from './log_entry_timeline_icon'; +import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; import * as i18 from '../../translations'; const useLogEntryUIProps = ( - logEntry: Immutable + logEntry: Immutable, + theme: ReturnType ): { actionEventTitle: string; + avatarColor: EuiAvatarProps['color']; + avatarIconColor: EuiAvatarProps['iconColor']; avatarSize: EuiAvatarProps['size']; commentText: string; commentType: EuiCommentProps['type']; displayComment: boolean; displayResponseEvent: boolean; + failedActionEventTitle: string; iconType: IconType; isResponseEvent: boolean; isSuccessful: boolean; + isCompleted: boolean; responseEventTitle: string; username: string | React.ReactNode; } => { @@ -34,15 +44,19 @@ const useLogEntryUIProps = ( let iconType: IconType = 'dot'; let commentType: EuiCommentProps['type'] = 'update'; let commentText: string = ''; + let avatarColor: EuiAvatarProps['color'] = theme.euiColorLightestShade; + let avatarIconColor: EuiAvatarProps['iconColor']; let avatarSize: EuiAvatarProps['size'] = 's'; + let failedActionEventTitle: string = ''; let isIsolateAction: boolean = false; let isResponseEvent: boolean = false; let isSuccessful: boolean = false; + let isCompleted: boolean = false; let displayComment: boolean = false; let displayResponseEvent: boolean = true; let username: EuiCommentProps['username'] = ''; - if (logEntry.type === 'action') { + if (logEntry.type === ActivityLogItemTypes.FLEET_ACTION) { avatarSize = 'm'; commentType = 'regular'; commentText = logEntry.item.data.data.comment?.trim() ?? ''; @@ -59,13 +73,51 @@ const useLogEntryUIProps = ( displayComment = true; } } - } else if (logEntry.type === 'response') { + } + if (logEntry.type === ActivityLogItemTypes.ACTION) { + avatarSize = 'm'; + commentType = 'regular'; + commentText = logEntry.item.data.EndpointActions.data.comment?.trim() ?? ''; + displayResponseEvent = false; + iconType = 'lockOpen'; + username = logEntry.item.data.user.id; + avatarIconColor = theme.euiColorVis9_behindText; + failedActionEventTitle = i18.ACTIVITY_LOG.LogEntry.action.failedEndpointReleaseAction; + if (logEntry.item.data.EndpointActions.data) { + const data = logEntry.item.data.EndpointActions.data; + if (data.command === 'isolate') { + iconType = 'lock'; + failedActionEventTitle = i18.ACTIVITY_LOG.LogEntry.action.failedEndpointIsolateAction; + } + if (commentText) { + displayComment = true; + } + } + } else if (logEntry.type === ActivityLogItemTypes.FLEET_RESPONSE) { isResponseEvent = true; if (logEntry.item.data.action_data.command === 'isolate') { isIsolateAction = true; } if (!!logEntry.item.data.completed_at && !logEntry.item.data.error) { isSuccessful = true; + } else { + avatarColor = theme.euiColorVis9_behindText; + } + } else if (logEntry.type === ActivityLogItemTypes.RESPONSE) { + iconType = 'check'; + isResponseEvent = true; + if (logEntry.item.data.EndpointActions.data.command === 'isolate') { + isIsolateAction = true; + } + if (logEntry.item.data.EndpointActions.completed_at) { + isCompleted = true; + if (!logEntry.item.data.error) { + isSuccessful = true; + avatarColor = theme.euiColorVis0_behindText; + } else { + isSuccessful = false; + avatarColor = theme.euiColorVis9_behindText; + } } } @@ -75,13 +127,23 @@ const useLogEntryUIProps = ( const getResponseEventTitle = () => { if (isIsolateAction) { - if (isSuccessful) { + if (isCompleted) { + if (isSuccessful) { + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndSuccessful; + } + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndUnsuccessful; + } else if (isSuccessful) { return i18.ACTIVITY_LOG.LogEntry.response.isolationSuccessful; } else { return i18.ACTIVITY_LOG.LogEntry.response.isolationFailed; } } else { - if (isSuccessful) { + if (isCompleted) { + if (isSuccessful) { + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndSuccessful; + } + return i18.ACTIVITY_LOG.LogEntry.response.unisolationCompletedAndUnsuccessful; + } else if (isSuccessful) { return i18.ACTIVITY_LOG.LogEntry.response.unisolationSuccessful; } else { return i18.ACTIVITY_LOG.LogEntry.response.unisolationFailed; @@ -91,18 +153,22 @@ const useLogEntryUIProps = ( return { actionEventTitle, + avatarColor, + avatarIconColor, avatarSize, commentText, commentType, displayComment, displayResponseEvent, + failedActionEventTitle, iconType, isResponseEvent, isSuccessful, + isCompleted, responseEventTitle: getResponseEventTitle(), username, }; - }, [logEntry]); + }, [logEntry, theme]); }; const StyledEuiComment = styled(EuiComment)` @@ -126,28 +192,41 @@ const StyledEuiComment = styled(EuiComment)` `; export const LogEntry = memo(({ logEntry }: { logEntry: Immutable }) => { + const theme = useEuiTheme(); const { actionEventTitle, + avatarColor, + avatarIconColor, avatarSize, commentText, commentType, displayComment, displayResponseEvent, + failedActionEventTitle, iconType, isResponseEvent, - isSuccessful, responseEventTitle, username, - } = useLogEntryUIProps(logEntry); + } = useLogEntryUIProps(logEntry, theme); return ( } - event={{displayResponseEvent ? responseEventTitle : actionEventTitle}} + event={ + + {displayResponseEvent + ? responseEventTitle + : failedActionEventTitle + ? failedActionEventTitle + : actionEventTitle} + + } timelineIcon={ - + } data-test-subj="timelineEntry" > diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx index 3ff311cd8a139..25e7c7d2c4a49 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/components/log_entry_timeline_icon.tsx @@ -7,32 +7,27 @@ import React, { memo } from 'react'; import { EuiAvatar, EuiAvatarProps } from '@elastic/eui'; -import { useEuiTheme } from '../../../../../../common/lib/theme/use_eui_theme'; export const LogEntryTimelineIcon = memo( ({ + avatarColor, + avatarIconColor, avatarSize, - isResponseEvent, - isSuccessful, iconType, + isResponseEvent, }: { + avatarColor: EuiAvatarProps['color']; + avatarIconColor?: EuiAvatarProps['iconColor']; avatarSize: EuiAvatarProps['size']; - isResponseEvent: boolean; - isSuccessful: boolean; iconType: EuiAvatarProps['iconType']; + isResponseEvent: boolean; }) => { - const euiTheme = useEuiTheme(); - return ( ); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx index 123a51e5a52bd..717368a1ff3a0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/endpoints.stories.tsx @@ -8,7 +8,11 @@ import React, { ComponentType } from 'react'; import moment from 'moment'; -import { ActivityLog, Immutable } from '../../../../../../common/endpoint/types'; +import { + ActivityLog, + Immutable, + ActivityLogItemTypes, +} from '../../../../../../common/endpoint/types'; import { EndpointDetailsFlyoutTabs } from './components/endpoint_details_tabs'; import { EndpointActivityLog } from './endpoint_activity_log'; import { EndpointDetailsFlyout } from '.'; @@ -26,7 +30,7 @@ export const dummyEndpointActivityLog = ( endDate: moment().toString(), data: [ { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -44,7 +48,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -63,7 +67,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { @@ -82,7 +86,7 @@ export const dummyEndpointActivityLog = ( }, }, { - type: 'action', + type: ActivityLogItemTypes.FLEET_ACTION, item: { id: '', data: { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index b2c438659b771..727c2e8a35024 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -42,6 +42,7 @@ import { import { getCurrentIsolationRequestState } from '../store/selectors'; import { licenseService } from '../../../../common/hooks/use_license'; import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator'; +import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator'; import { APP_PATH, MANAGEMENT_PATH, @@ -807,7 +808,7 @@ describe('when on the endpoint list page', () => { let renderResult: ReturnType; const agentId = 'some_agent_id'; - let getMockData: () => ActivityLog; + let getMockData: (option?: { hasLogsEndpointActionResponses?: boolean }) => ActivityLog; beforeEach(async () => { window.IntersectionObserver = jest.fn(() => ({ root: null, @@ -828,10 +829,15 @@ describe('when on the endpoint list page', () => { }); const fleetActionGenerator = new FleetActionGenerator('seed'); - const responseData = fleetActionGenerator.generateResponse({ + const endpointActionGenerator = new EndpointActionGenerator('seed'); + const endpointResponseData = endpointActionGenerator.generateResponse({ + agent: { id: agentId }, + }); + const fleetResponseData = fleetActionGenerator.generateResponse({ agent_id: agentId, }); - const actionData = fleetActionGenerator.generate({ + + const fleetActionData = fleetActionGenerator.generate({ agents: [agentId], data: { comment: 'some comment', @@ -844,35 +850,49 @@ describe('when on the endpoint list page', () => { }, }); - getMockData = () => ({ - page: 1, - pageSize: 50, - startDate: 'now-1d', - endDate: 'now', - data: [ - { - type: 'response', - item: { - id: 'some_id_0', - data: responseData, + getMockData = (hasLogsEndpointActionResponses?: { + hasLogsEndpointActionResponses?: boolean; + }) => { + const response: ActivityLog = { + page: 1, + pageSize: 50, + startDate: 'now-1d', + endDate: 'now', + data: [ + { + type: 'fleetResponse', + item: { + id: 'some_id_1', + data: fleetResponseData, + }, }, - }, - { - type: 'action', - item: { - id: 'some_id_1', - data: actionData, + { + type: 'fleetAction', + item: { + id: 'some_id_2', + data: fleetActionData, + }, }, - }, - { - type: 'action', + { + type: 'fleetAction', + item: { + id: 'some_id_3', + data: isolatedActionData, + }, + }, + ], + }; + if (hasLogsEndpointActionResponses) { + response.data.unshift({ + type: 'response', item: { - id: 'some_id_3', - data: isolatedActionData, + id: 'some_id_0', + data: endpointResponseData, }, - }, - ], - }); + }); + } + return response; + }; renderResult = render(); await reactTestingLibrary.act(async () => { @@ -912,6 +932,25 @@ describe('when on the endpoint list page', () => { expect(`${logEntries[1]} .euiCommentTimeline__icon--regular`).not.toBe(null); }); + it('should display log accurately with endpoint responses', async () => { + const activityLogTab = await renderResult.findByTestId('activity_log'); + reactTestingLibrary.act(() => { + reactTestingLibrary.fireEvent.click(activityLogTab); + }); + await middlewareSpy.waitForAction('endpointDetailsActivityLogChanged'); + reactTestingLibrary.act(() => { + dispatchEndpointDetailsActivityLogChanged( + 'success', + getMockData({ hasLogsEndpointActionResponses: true }) + ); + }); + const logEntries = await renderResult.queryAllByTestId('timelineEntry'); + expect(logEntries.length).toEqual(4); + expect(`${logEntries[0]} .euiCommentTimeline__icon--update`).not.toBe(null); + expect(`${logEntries[1]} .euiCommentTimeline__icon--update`).not.toBe(null); + expect(`${logEntries[2]} .euiCommentTimeline__icon--regular`).not.toBe(null); + }); + it('should display empty state when API call has failed', async () => { const activityLogTab = await renderResult.findByTestId('activity_log'); reactTestingLibrary.act(() => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts index c8a29eed3fda7..9cd55a70005ec 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/translations.ts @@ -56,8 +56,44 @@ export const ACTIVITY_LOG = { defaultMessage: 'submitted request: Release host', } ), + failedEndpointReleaseAction: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.failedEndpointReleaseAction', + { + defaultMessage: 'failed to submit request: Release host', + } + ), + failedEndpointIsolateAction: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.action.failedEndpointIsolateAction', + { + defaultMessage: 'failed to submit request: Isolate host', + } + ), }, response: { + isolationCompletedAndSuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationCompletedAndSuccessful', + { + defaultMessage: 'Host isolation request completed by Endpoint', + } + ), + isolationCompletedAndUnsuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationCompletedAndUnsuccessful', + { + defaultMessage: 'Host isolation request completed by Endpoint with errors', + } + ), + unisolationCompletedAndSuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationCompletedAndSuccessful', + { + defaultMessage: 'Release request completed by Endpoint', + } + ), + unisolationCompletedAndUnsuccessful: i18n.translate( + 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.unisolationCompletedAndUnsuccessful', + { + defaultMessage: 'Release request completed by Endpoint with errors', + } + ), isolationSuccessful: i18n.translate( 'xpack.securitySolution.endpointDetails.activityLog.logEntry.response.isolationSuccessful', { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts index 4bd63c83169e5..5ce7962000788 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/audit_log.test.ts @@ -30,9 +30,15 @@ import { } from '../../mocks'; import { registerActionAuditLogRoutes } from './audit_log'; import uuid from 'uuid'; -import { aMockAction, aMockResponse, MockAction, mockSearchResult, MockResponse } from './mocks'; +import { mockAuditLogSearchResult, Results } from './mocks'; import { SecuritySolutionRequestHandlerContext } from '../../../types'; -import { ActivityLog } from '../../../../common/endpoint/types'; +import { + ActivityLog, + EndpointAction, + EndpointActionResponse, +} from '../../../../common/endpoint/types'; +import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator'; +import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; describe('Action Log API', () => { describe('schema', () => { @@ -93,17 +99,30 @@ describe('Action Log API', () => { }); describe('response', () => { - const mockID = 'XYZABC-000'; - const actionID = 'some-known-actionid'; + const mockAgentID = 'XYZABC-000'; let endpointAppContextService: EndpointAppContextService; + const fleetActionGenerator = new FleetActionGenerator('seed'); + const endpointActionGenerator = new EndpointActionGenerator('seed'); // convenience for calling the route and handler for audit log let getActivityLog: ( params: EndpointActionLogRequestParams, query?: EndpointActionLogRequestQuery ) => Promise>; - // convenience for injecting mock responses for actions index and responses - let havingActionsAndResponses: (actions: MockAction[], responses: MockResponse[]) => void; + + // convenience for injecting mock action requests and responses + // for .logs-endpoint and .fleet indices + let mockActions: ({ + numActions, + hasFleetActions, + hasFleetResponses, + hasResponses, + }: { + numActions: number; + hasFleetActions?: boolean; + hasFleetResponses?: boolean; + hasResponses?: boolean; + }) => void; let havingErrors: () => void; @@ -149,12 +168,113 @@ describe('Action Log API', () => { return mockResponse; }; - havingActionsAndResponses = (actions: MockAction[], responses: MockResponse[]) => { - esClientMock.asCurrentUser.search = jest.fn().mockImplementation((req) => { - const items: any[] = - req.index === '.fleet-actions' ? actions.splice(0, 50) : responses.splice(0, 1000); + // some arbitrary ids for needed actions + const getMockActionIds = (numAction: number): string[] => { + return [...Array(numAction).keys()].map(() => Math.random().toString(36).split('.')[1]); + }; + + // create as many actions as needed + const getEndpointActionsData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + endpointActionGenerator.generate({ + agent: { id: mockAgentID }, + EndpointActions: { + action_id: actionId, + }, + }) + ); + return data; + }; + // create as many responses as needed + const getEndpointResponseData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + endpointActionGenerator.generateResponse({ + agent: { id: mockAgentID }, + EndpointActions: { + action_id: actionId, + }, + }) + ); + return data; + }; + // create as many fleet actions as needed + const getFleetResponseData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + fleetActionGenerator.generateResponse({ + agent_id: mockAgentID, + action_id: actionId, + }) + ); + return data; + }; + // create as many fleet responses as needed + const getFleetActionData = (actionIds: string[]) => { + const data = actionIds.map((actionId) => + fleetActionGenerator.generate({ + agents: [mockAgentID], + action_id: actionId, + data: { + comment: 'some comment', + }, + }) + ); + return data; + }; + + // mock actions and responses results in a single response + mockActions = ({ + numActions, + hasFleetActions = false, + hasFleetResponses = false, + hasResponses = false, + }: { + numActions: number; + hasFleetActions?: boolean; + hasFleetResponses?: boolean; + hasResponses?: boolean; + }) => { + esClientMock.asCurrentUser.search = jest.fn().mockImplementationOnce(() => { + let actions: Results[] = []; + let fleetActions: Results[] = []; + let responses: Results[] = []; + let fleetResponses: Results[] = []; + + const actionIds = getMockActionIds(numActions); + + actions = getEndpointActionsData(actionIds).map((e) => ({ + _index: '.ds-.logs-endpoint.actions-default-2021.19.10-000001', + _source: e, + })); + + if (hasFleetActions) { + fleetActions = getFleetActionData(actionIds).map((e) => ({ + _index: '.fleet-actions-7', + _source: e, + })); + } - return Promise.resolve(mockSearchResult(items.map((x) => x.build()))); + if (hasFleetResponses) { + fleetResponses = getFleetResponseData(actionIds).map((e) => ({ + _index: '.ds-.fleet-actions-results-2021.19.10-000001', + _source: e, + })); + } + + if (hasResponses) { + responses = getEndpointResponseData(actionIds).map((e) => ({ + _index: '.ds-.logs-endpoint.action.responses-default-2021.19.10-000001', + _source: e, + })); + } + + const results = mockAuditLogSearchResult([ + ...actions, + ...fleetActions, + ...responses, + ...fleetResponses, + ]); + + return Promise.resolve(results); }); }; @@ -172,45 +292,80 @@ describe('Action Log API', () => { }); it('should return an empty array when nothing in audit log', async () => { - havingActionsAndResponses([], []); - const response = await getActivityLog({ agent_id: mockID }); + mockActions({ numActions: 0 }); + + const response = await getActivityLog({ agent_id: mockAgentID }); expect(response.ok).toBeCalled(); expect((response.ok.mock.calls[0][0]?.body as ActivityLog).data).toHaveLength(0); }); - it('should have actions and action responses', async () => { - havingActionsAndResponses( - [ - aMockAction().withAgent(mockID).withAction('isolate').withID(actionID), - aMockAction().withAgent(mockID).withAction('unisolate'), - ], - [aMockResponse(actionID, mockID).forAction(actionID).forAgent(mockID)] - ); - const response = await getActivityLog({ agent_id: mockID }); + it('should return fleet actions, fleet responses and endpoint responses', async () => { + mockActions({ + numActions: 2, + hasFleetActions: true, + hasFleetResponses: true, + hasResponses: true, + }); + + const response = await getActivityLog({ agent_id: mockAgentID }); + const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; + expect(response.ok).toBeCalled(); + expect(responseBody.data).toHaveLength(6); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointActionResponse).completed_at) + ).toHaveLength(2); + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + }); + + it('should return only fleet actions and no responses', async () => { + mockActions({ numActions: 2, hasFleetActions: true }); + + const response = await getActivityLog({ agent_id: mockAgentID }); const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; + expect(response.ok).toBeCalled(); + expect(responseBody.data).toHaveLength(2); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + }); + + it('should only have fleet data', async () => { + mockActions({ numActions: 2, hasFleetActions: true, hasFleetResponses: true }); + const response = await getActivityLog({ agent_id: mockAgentID }); + const responseBody = response.ok.mock.calls[0][0]?.body as ActivityLog; expect(response.ok).toBeCalled(); - expect(responseBody.data).toHaveLength(3); - expect(responseBody.data.filter((e) => e.type === 'response')).toHaveLength(1); - expect(responseBody.data.filter((e) => e.type === 'action')).toHaveLength(2); + expect(responseBody.data).toHaveLength(4); + + expect( + responseBody.data.filter((e) => (e.item.data as EndpointAction).expiration) + ).toHaveLength(2); + expect( + responseBody.data.filter((e) => (e.item.data as EndpointActionResponse).completed_at) + ).toHaveLength(2); }); it('should throw errors when no results for some agentID', async () => { havingErrors(); try { - await getActivityLog({ agent_id: mockID }); + await getActivityLog({ agent_id: mockAgentID }); } catch (error) { - expect(error.message).toEqual(`Error fetching actions log for agent_id ${mockID}`); + expect(error.message).toEqual(`Error fetching actions log for agent_id ${mockAgentID}`); } }); it('should return date ranges if present in the query', async () => { - havingActionsAndResponses([], []); + mockActions({ numActions: 0 }); + const startDate = new Date(new Date().setDate(new Date().getDate() - 1)).toISOString(); const endDate = new Date().toISOString(); const response = await getActivityLog( - { agent_id: mockID }, + { agent_id: mockAgentID }, { page: 1, page_size: 50, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts index e12299bedbb34..02f0cb4867646 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/isolation.ts @@ -17,6 +17,7 @@ import { ENDPOINT_ACTION_RESPONSES_DS, ISOLATE_HOST_ROUTE, UNISOLATE_HOST_ROUTE, + failedFleetActionErrorCode, } from '../../../../common/endpoint/constants'; import { AGENT_ACTIONS_INDEX } from '../../../../../fleet/common'; import { @@ -33,6 +34,7 @@ import { getMetadataForEndpoints } from '../../services'; import { EndpointAppContext } from '../../types'; import { APP_ID } from '../../../../common/constants'; import { userCanIsolate } from '../../../../common/endpoint/actions'; +import { doLogsEndpointActionDsExists } from '../../utils'; /** * Registers the Host-(un-)isolation routes @@ -78,7 +80,7 @@ const createFailedActionResponseEntry = async ({ body: { ...doc, error: { - code: '424', + code: failedFleetActionErrorCode, message: 'Failed to deliver action request to fleet', }, }, @@ -88,31 +90,6 @@ const createFailedActionResponseEntry = async ({ } }; -const doLogsEndpointActionDsExists = async ({ - context, - logger, - dataStreamName, -}: { - context: SecuritySolutionRequestHandlerContext; - logger: Logger; - dataStreamName: string; -}): Promise => { - try { - const esClient = context.core.elasticsearch.client.asInternalUser; - const doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ - name: dataStreamName, - }); - return doesIndexTemplateExist.statusCode === 404 ? false : true; - } catch (error) { - const errorType = error?.type ?? ''; - if (errorType !== 'resource_not_found_exception') { - logger.error(error); - throw error; - } - return false; - } -}; - export const isolationRequestHandler = function ( endpointContext: EndpointAppContext, isolate: boolean diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts index 34f7d140a78de..b50d80a9bae71 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/mocks.ts @@ -13,11 +13,43 @@ import { ApiResponse } from '@elastic/elasticsearch'; import moment from 'moment'; import uuid from 'uuid'; import { + LogsEndpointAction, + LogsEndpointActionResponse, EndpointAction, EndpointActionResponse, ISOLATION_ACTIONS, } from '../../../../common/endpoint/types'; +export interface Results { + _index: string; + _source: + | LogsEndpointAction + | LogsEndpointActionResponse + | EndpointAction + | EndpointActionResponse; +} +export const mockAuditLogSearchResult = (results?: Results[]) => { + const response = { + body: { + hits: { + total: { value: results?.length ?? 0, relation: 'eq' }, + hits: + results?.map((a: Results) => ({ + _index: a._index, + _id: Math.random().toString(36).split('.')[1], + _score: 0.0, + _source: a._source, + })) ?? [], + }, + }, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + }; + return response; +}; + export const mockSearchResult = (results: any = []): ApiResponse => { return { body: { diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts index 711d78ba51b59..d59ecb674196c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions.ts @@ -6,15 +6,28 @@ */ import { ElasticsearchClient, Logger } from 'kibana/server'; +import { SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; +import { ApiResponse } from '@elastic/elasticsearch'; import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; import { SecuritySolutionRequestHandlerContext } from '../../types'; import { ActivityLog, + ActivityLogEntry, EndpointAction, + LogsEndpointAction, EndpointActionResponse, EndpointPendingActions, + LogsEndpointActionResponse, } from '../../../common/endpoint/types'; -import { catchAndWrapError } from '../utils'; +import { + catchAndWrapError, + categorizeActionResults, + categorizeResponseResults, + getActionRequestsResult, + getActionResponsesResult, + getTimeSortedData, + getUniqueLogData, +} from '../utils'; import { EndpointMetadataService } from './metadata'; const PENDING_ACTION_RESPONSE_MAX_LAPSED_TIME = 300000; // 300k ms === 5 minutes @@ -38,9 +51,9 @@ export const getAuditLogResponse = async ({ }): Promise => { const size = Math.floor(pageSize / 2); const from = page <= 1 ? 0 : page * size - size + 1; - const esClient = context.core.elasticsearch.client.asCurrentUser; + const data = await getActivityLog({ - esClient, + context, from, size, startDate, @@ -59,7 +72,7 @@ export const getAuditLogResponse = async ({ }; const getActivityLog = async ({ - esClient, + context, size, from, startDate, @@ -67,83 +80,39 @@ const getActivityLog = async ({ elasticAgentId, logger, }: { - esClient: ElasticsearchClient; + context: SecuritySolutionRequestHandlerContext; elasticAgentId: string; size: number; from: number; startDate: string; endDate: string; logger: Logger; -}) => { - const options = { - headers: { - 'X-elastic-product-origin': 'fleet', - }, - ignore: [404], - }; - - let actionsResult; - let responsesResult; - const dateFilters = [ - { range: { '@timestamp': { gte: startDate } } }, - { range: { '@timestamp': { lte: endDate } } }, - ]; +}): Promise => { + let actionsResult: ApiResponse, unknown>; + let responsesResult: ApiResponse, unknown>; try { // fetch actions with matching agent_id - const baseActionFilters = [ - { term: { agents: elasticAgentId } }, - { term: { input_type: 'endpoint' } }, - { term: { type: 'INPUT_ACTION' } }, - ]; - const actionsFilters = [...baseActionFilters, ...dateFilters]; - actionsResult = await esClient.search( - { - index: AGENT_ACTIONS_INDEX, - size, - from, - body: { - query: { - bool: { - // @ts-ignore - filter: actionsFilters, - }, - }, - sort: [ - { - '@timestamp': { - order: 'desc', - }, - }, - ], - }, - }, - options - ); - const actionIds = actionsResult?.body?.hits?.hits?.map( - (e) => (e._source as EndpointAction).action_id - ); + const { actionIds, actionRequests } = await getActionRequestsResult({ + context, + logger, + elasticAgentId, + startDate, + endDate, + size, + from, + }); + actionsResult = actionRequests; - // fetch responses with matching `action_id`s - const baseResponsesFilter = [ - { term: { agent_id: elasticAgentId } }, - { terms: { action_id: actionIds } }, - ]; - const responsesFilters = [...baseResponsesFilter, ...dateFilters]; - responsesResult = await esClient.search( - { - index: AGENT_ACTIONS_RESULTS_INDEX, - size: 1000, - body: { - query: { - bool: { - filter: responsesFilters, - }, - }, - }, - }, - options - ); + // fetch responses with matching unique set of `action_id`s + responsesResult = await getActionResponsesResult({ + actionIds: [...new Set(actionIds)], // de-dupe `action_id`s + context, + logger, + elasticAgentId, + startDate, + endDate, + }); } catch (error) { logger.error(error); throw error; @@ -153,21 +122,26 @@ const getActivityLog = async ({ throw new Error(`Error fetching actions log for agent_id ${elasticAgentId}`); } - const responses = responsesResult?.body?.hits?.hits?.length - ? responsesResult?.body?.hits?.hits?.map((e) => ({ - type: 'response', - item: { id: e._id, data: e._source }, - })) - : []; - const actions = actionsResult?.body?.hits?.hits?.length - ? actionsResult?.body?.hits?.hits?.map((e) => ({ - type: 'action', - item: { id: e._id, data: e._source }, - })) - : []; - const sortedData = ([...responses, ...actions] as ActivityLog['data']).sort((a, b) => - new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 - ); + // label record as `action`, `fleetAction` + const responses = categorizeResponseResults({ + results: responsesResult?.body?.hits?.hits as Array< + SearchHit + >, + }); + + // label record as `response`, `fleetResponse` + const actions = categorizeActionResults({ + results: actionsResult?.body?.hits?.hits as Array< + SearchHit + >, + }); + + // filter out the duplicate endpoint actions that also have fleetActions + // include endpoint actions that have no fleet actions + const uniqueLogData = getUniqueLogData([...responses, ...actions]); + + // sort by @timestamp in desc order, newest first + const sortedData = getTimeSortedData(uniqueLogData); return sortedData; }; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts new file mode 100644 index 0000000000000..f75b265bf24d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/audit_log_helpers.ts @@ -0,0 +1,266 @@ +/* + * 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 { Logger } from 'kibana/server'; +import { SearchRequest } from 'src/plugins/data/public'; +import { SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; +import { ApiResponse } from '@elastic/elasticsearch'; +import { AGENT_ACTIONS_INDEX, AGENT_ACTIONS_RESULTS_INDEX } from '../../../../fleet/common'; +import { + ENDPOINT_ACTIONS_INDEX, + ENDPOINT_ACTION_RESPONSES_INDEX, + failedFleetActionErrorCode, +} from '../../../common/endpoint/constants'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { + ActivityLog, + ActivityLogAction, + EndpointActivityLogAction, + ActivityLogActionResponse, + EndpointActivityLogActionResponse, + ActivityLogItemTypes, + EndpointAction, + LogsEndpointAction, + EndpointActionResponse, + LogsEndpointActionResponse, + ActivityLogEntry, +} from '../../../common/endpoint/types'; +import { doesLogsEndpointActionsIndexExist } from '../utils'; + +const actionsIndices = [AGENT_ACTIONS_INDEX, ENDPOINT_ACTIONS_INDEX]; +const responseIndices = [AGENT_ACTIONS_RESULTS_INDEX, ENDPOINT_ACTION_RESPONSES_INDEX]; +export const logsEndpointActionsRegex = new RegExp(`(^\.ds-\.logs-endpoint\.actions-default-).+`); +export const logsEndpointResponsesRegex = new RegExp( + `(^\.ds-\.logs-endpoint\.action\.responses-default-).+` +); +const queryOptions = { + headers: { + 'X-elastic-product-origin': 'fleet', + }, + ignore: [404], +}; + +const getDateFilters = ({ startDate, endDate }: { startDate: string; endDate: string }) => { + return [ + { range: { '@timestamp': { gte: startDate } } }, + { range: { '@timestamp': { lte: endDate } } }, + ]; +}; + +export const getUniqueLogData = (activityLogEntries: ActivityLogEntry[]): ActivityLogEntry[] => { + // find the error responses for actions that didn't make it to fleet index + const onlyResponsesForFleetErrors = activityLogEntries + .filter( + (e) => + e.type === ActivityLogItemTypes.RESPONSE && + e.item.data.error?.code === failedFleetActionErrorCode + ) + .map( + (e: ActivityLogEntry) => (e.item.data as LogsEndpointActionResponse).EndpointActions.action_id + ); + + // all actions and responses minus endpoint actions. + const nonEndpointActionsDocs = activityLogEntries.filter( + (e) => e.type !== ActivityLogItemTypes.ACTION + ); + + // only endpoint actions that match the error responses + const onlyEndpointActionsDocWithoutFleetActions = activityLogEntries + .filter((e) => e.type === ActivityLogItemTypes.ACTION) + .filter((e: ActivityLogEntry) => + onlyResponsesForFleetErrors.includes( + (e.item.data as LogsEndpointAction).EndpointActions.action_id + ) + ); + + // join the error actions and the rest + return [...nonEndpointActionsDocs, ...onlyEndpointActionsDocWithoutFleetActions]; +}; + +export const categorizeResponseResults = ({ + results, +}: { + results: Array>; +}): Array => { + return results?.length + ? results?.map((e) => { + const isResponseDoc: boolean = matchesIndexPattern({ + regexPattern: logsEndpointResponsesRegex, + index: e._index, + }); + return isResponseDoc + ? { + type: ActivityLogItemTypes.RESPONSE, + item: { id: e._id, data: e._source as LogsEndpointActionResponse }, + } + : { + type: ActivityLogItemTypes.FLEET_RESPONSE, + item: { id: e._id, data: e._source as EndpointActionResponse }, + }; + }) + : []; +}; + +export const categorizeActionResults = ({ + results, +}: { + results: Array>; +}): Array => { + return results?.length + ? results?.map((e) => { + const isActionDoc: boolean = matchesIndexPattern({ + regexPattern: logsEndpointActionsRegex, + index: e._index, + }); + return isActionDoc + ? { + type: ActivityLogItemTypes.ACTION, + item: { id: e._id, data: e._source as LogsEndpointAction }, + } + : { + type: ActivityLogItemTypes.FLEET_ACTION, + item: { id: e._id, data: e._source as EndpointAction }, + }; + }) + : []; +}; + +export const getTimeSortedData = (data: ActivityLog['data']): ActivityLog['data'] => { + return data.sort((a, b) => + new Date(b.item.data['@timestamp']) > new Date(a.item.data['@timestamp']) ? 1 : -1 + ); +}; + +export const getActionRequestsResult = async ({ + context, + logger, + elasticAgentId, + startDate, + endDate, + size, + from, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + elasticAgentId: string; + startDate: string; + endDate: string; + size: number; + from: number; +}): Promise<{ + actionIds: string[]; + actionRequests: ApiResponse, unknown>; +}> => { + const dateFilters = getDateFilters({ startDate, endDate }); + const baseActionFilters = [ + { term: { agents: elasticAgentId } }, + { term: { input_type: 'endpoint' } }, + { term: { type: 'INPUT_ACTION' } }, + ]; + const actionsFilters = [...baseActionFilters, ...dateFilters]; + + const hasLogsEndpointActionsIndex = await doesLogsEndpointActionsIndexExist({ + context, + logger, + indexName: ENDPOINT_ACTIONS_INDEX, + }); + + const actionsSearchQuery: SearchRequest = { + index: hasLogsEndpointActionsIndex ? actionsIndices : AGENT_ACTIONS_INDEX, + size, + from, + body: { + query: { + bool: { + filter: actionsFilters, + }, + }, + sort: [ + { + '@timestamp': { + order: 'desc', + }, + }, + ], + }, + }; + + let actionRequests: ApiResponse, unknown>; + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + actionRequests = await esClient.search(actionsSearchQuery, queryOptions); + const actionIds = actionRequests?.body?.hits?.hits?.map((e) => { + return logsEndpointActionsRegex.test(e._index) + ? (e._source as LogsEndpointAction).EndpointActions.action_id + : (e._source as EndpointAction).action_id; + }); + + return { actionIds, actionRequests }; + } catch (error) { + logger.error(error); + throw error; + } +}; + +export const getActionResponsesResult = async ({ + context, + logger, + elasticAgentId, + actionIds, + startDate, + endDate, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + elasticAgentId: string; + actionIds: string[]; + startDate: string; + endDate: string; +}): Promise, unknown>> => { + const dateFilters = getDateFilters({ startDate, endDate }); + const baseResponsesFilter = [ + { term: { agent_id: elasticAgentId } }, + { terms: { action_id: actionIds } }, + ]; + const responsesFilters = [...baseResponsesFilter, ...dateFilters]; + + const hasLogsEndpointActionResponsesIndex = await doesLogsEndpointActionsIndexExist({ + context, + logger, + indexName: ENDPOINT_ACTION_RESPONSES_INDEX, + }); + + const responsesSearchQuery: SearchRequest = { + index: hasLogsEndpointActionResponsesIndex ? responseIndices : AGENT_ACTIONS_RESULTS_INDEX, + size: 1000, + body: { + query: { + bool: { + filter: responsesFilters, + }, + }, + }, + }; + + let actionResponses: ApiResponse, unknown>; + try { + const esClient = context.core.elasticsearch.client.asCurrentUser; + actionResponses = await esClient.search(responsesSearchQuery, queryOptions); + } catch (error) { + logger.error(error); + throw error; + } + return actionResponses; +}; + +const matchesIndexPattern = ({ + regexPattern, + index, +}: { + regexPattern: RegExp; + index: string; +}): boolean => regexPattern.test(index); diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/utils/index.ts index 34cabf79aff0e..6c40073f8c654 100644 --- a/x-pack/plugins/security_solution/server/endpoint/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/utils/index.ts @@ -7,3 +7,5 @@ export * from './fleet_agent_status_to_endpoint_host_status'; export * from './wrap_errors'; +export * from './audit_log_helpers'; +export * from './yes_no_data_stream'; diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts new file mode 100644 index 0000000000000..d2894c8c64c14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { + elasticsearchServiceMock, + savedObjectsClientMock, + loggingSystemMock, +} from 'src/core/server/mocks'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; +import { createRouteHandlerContext } from '../mocks'; +import { + doLogsEndpointActionDsExists, + doesLogsEndpointActionsIndexExist, +} from './yes_no_data_stream'; + +describe('Accurately answers if index template for data stream exists', () => { + let ctxt: jest.Mocked; + + beforeEach(() => { + ctxt = createRouteHandlerContext( + elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClientMock.create() + ); + }); + + const mockEsApiResponse = (response: { body: boolean; statusCode: number }) => { + return jest.fn().mockImplementationOnce(() => Promise.resolve(response)); + }; + + it('Returns FALSE for a non-existent data stream index template', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = mockEsApiResponse({ + body: false, + statusCode: 404, + }); + const doesItExist = await doLogsEndpointActionDsExists({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + dataStreamName: '.test-stream.name', + }); + expect(doesItExist).toBeFalsy(); + }); + + it('Returns TRUE for an existing index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.existsIndexTemplate = mockEsApiResponse({ + body: true, + statusCode: 200, + }); + const doesItExist = await doLogsEndpointActionDsExists({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + dataStreamName: '.test-stream.name', + }); + expect(doesItExist).toBeTruthy(); + }); +}); + +describe('Accurately answers if index exists', () => { + let ctxt: jest.Mocked; + + beforeEach(() => { + ctxt = createRouteHandlerContext( + elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClientMock.create() + ); + }); + + const mockEsApiResponse = (response: { body: boolean; statusCode: number }) => { + return jest.fn().mockImplementationOnce(() => Promise.resolve(response)); + }; + + it('Returns FALSE for a non-existent index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.exists = mockEsApiResponse({ + body: false, + statusCode: 404, + }); + const doesItExist = await doesLogsEndpointActionsIndexExist({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + indexName: '.test-index.name-default', + }); + expect(doesItExist).toBeFalsy(); + }); + + it('Returns TRUE for an existing index', async () => { + ctxt.core.elasticsearch.client.asInternalUser.indices.exists = mockEsApiResponse({ + body: true, + statusCode: 200, + }); + const doesItExist = await doesLogsEndpointActionsIndexExist({ + context: ctxt, + logger: loggingSystemMock.create().get('host-isolation'), + indexName: '.test-index.name-default', + }); + expect(doesItExist).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts new file mode 100644 index 0000000000000..dea2e46c3c258 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/utils/yes_no_data_stream.ts @@ -0,0 +1,59 @@ +/* + * 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 { Logger } from 'src/core/server'; +import { SecuritySolutionRequestHandlerContext } from '../../types'; + +export const doLogsEndpointActionDsExists = async ({ + context, + logger, + dataStreamName, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + dataStreamName: string; +}): Promise => { + try { + const esClient = context.core.elasticsearch.client.asInternalUser; + const doesIndexTemplateExist = await esClient.indices.existsIndexTemplate({ + name: dataStreamName, + }); + return doesIndexTemplateExist.statusCode === 404 ? false : true; + } catch (error) { + const errorType = error?.type ?? ''; + if (errorType !== 'resource_not_found_exception') { + logger.error(error); + throw error; + } + return false; + } +}; + +export const doesLogsEndpointActionsIndexExist = async ({ + context, + logger, + indexName, +}: { + context: SecuritySolutionRequestHandlerContext; + logger: Logger; + indexName: string; +}): Promise => { + try { + const esClient = context.core.elasticsearch.client.asInternalUser; + const doesIndexExist = await esClient.indices.exists({ + index: indexName, + }); + return doesIndexExist.statusCode === 404 ? false : true; + } catch (error) { + const errorType = error?.type ?? ''; + if (errorType !== 'index_not_found_exception') { + logger.error(error); + throw error; + } + return false; + } +}; From 78c2a33243690975b048da2aebaf925a93705972 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 05:51:56 -0400 Subject: [PATCH 43/54] [Security Solution][Endpoint] Fix unhandled promise rejections in skipped tests (#115354) (#115399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix errors and comment code in middleware (pending to fix this) * Fix endpoint list middleware test * Fix policy TA layout test * Fix test returning missing promise Co-authored-by: David Sánchez --- .../search_exceptions.test.tsx | 1 - .../management/pages/endpoint_hosts/mocks.ts | 39 ++++++------- .../endpoint_hosts/store/middleware.test.ts | 4 +- .../policy_trusted_apps_layout.test.tsx | 56 +++++++++++++------ 4 files changed, 59 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx index d7db249475df7..084978d35d03a 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx @@ -20,7 +20,6 @@ jest.mock('../../../common/components/user_privileges/use_endpoint_privileges'); let onSearchMock: jest.Mock; const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 describe('Search exceptions', () => { let appTestContext: AppContextTestRender; let renderResult: ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts index e0b5837c2f78a..c724773593f53 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -122,30 +122,27 @@ export const endpointActivityLogHttpMock = const responseData = fleetActionGenerator.generateResponse({ agent_id: endpointMetadata.agent.id, }); - return { - body: { - page: 1, - pageSize: 50, - startDate: 'now-1d', - endDate: 'now', - data: [ - { - type: 'response', - item: { - id: '', - data: responseData, - }, + page: 1, + pageSize: 50, + startDate: 'now-1d', + endDate: 'now', + data: [ + { + type: 'response', + item: { + id: '', + data: responseData, }, - { - type: 'action', - item: { - id: '', - data: actionData, - }, + }, + { + type: 'action', + item: { + id: '', + data: actionData, }, - ], - }, + }, + ], }; }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts index 43fa4e104067f..81c4dc6f2f7de 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.test.ts @@ -61,8 +61,7 @@ jest.mock('../../../../common/lib/kibana'); type EndpointListStore = Store, Immutable>; -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -describe.skip('endpoint list middleware', () => { +describe('endpoint list middleware', () => { const getKibanaServicesMock = KibanaServices.get as jest.Mock; let fakeCoreStart: jest.Mocked; let depsStart: DepsStartMock; @@ -390,7 +389,6 @@ describe.skip('endpoint list middleware', () => { it('should call get Activity Log API with correct paging options', async () => { dispatchUserChangedUrl(); - const updatePagingDispatched = waitForAction('endpointDetailsActivityLogUpdatePaging'); dispatchGetActivityLogPaging({ page: 3 }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx index e519d19d60fdc..43e19c00bcc8e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx @@ -19,20 +19,14 @@ import { createLoadedResourceState, isLoadedResourceState } from '../../../../.. import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { policyListApiPathHandlers } from '../../../store/test_mock_utils'; -import { licenseService } from '../../../../../../common/hooks/use_license'; +import { + EndpointPrivileges, + useEndpointPrivileges, +} from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; jest.mock('../../../../trusted_apps/service'); -jest.mock('../../../../../../common/hooks/use_license', () => { - const licenseServiceInstance = { - isPlatinumPlus: jest.fn(), - }; - return { - licenseService: licenseServiceInstance, - useLicense: () => { - return licenseServiceInstance; - }, - }; -}); +jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); +const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; let mockedContext: AppContextTestRender; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; @@ -42,8 +36,17 @@ let coreStart: AppContextTestRender['coreStart']; let http: typeof coreStart.http; const generator = new EndpointDocGenerator(); -// unhandled promise rejection: https://github.com/elastic/kibana/issues/112699 -describe.skip('Policy trusted apps layout', () => { +describe('Policy trusted apps layout', () => { + const loadedUserEndpointPrivilegesState = ( + endpointOverrides: Partial = {} + ): EndpointPrivileges => ({ + loading: false, + canAccessFleet: true, + canAccessEndpointManagement: true, + isPlatinumPlus: true, + ...endpointOverrides, + }); + beforeEach(() => { mockedContext = createAppRootMockRenderer(); http = mockedContext.coreStart.http; @@ -59,6 +62,14 @@ describe.skip('Policy trusted apps layout', () => { }); } + // GET Agent status for agent policy + if (path === '/api/fleet/agent-status') { + return Promise.resolve({ + results: { events: 0, total: 5, online: 3, error: 1, offline: 1 }, + success: true, + }); + } + // Get package data // Used in tests that route back to the list if (policyListApiHandlers[path]) { @@ -78,6 +89,10 @@ describe.skip('Policy trusted apps layout', () => { render = () => mockedContext.render(); }); + afterAll(() => { + mockUseEndpointPrivileges.mockReset(); + }); + afterEach(() => reactTestingLibrary.cleanup()); it('should renders layout with no existing TA data', async () => { @@ -121,7 +136,11 @@ describe.skip('Policy trusted apps layout', () => { }); it('should hide assign button on empty state with unassigned policies when downgraded to a gold or below license', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + mockUseEndpointPrivileges.mockReturnValue( + loadedUserEndpointPrivilegesState({ + isPlatinumPlus: false, + }) + ); const component = render(); mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234')); @@ -133,8 +152,13 @@ describe.skip('Policy trusted apps layout', () => { }); expect(component.queryByTestId('assign-ta-button')).toBeNull(); }); + it('should hide the `Assign trusted applications` button when there is data and the license is downgraded to gold or below', async () => { - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(false); + mockUseEndpointPrivileges.mockReturnValue( + loadedUserEndpointPrivilegesState({ + isPlatinumPlus: false, + }) + ); TrustedAppsHttpServiceMock.mockImplementation(() => { return { getTrustedAppsList: () => getMockListResponse(), From 2d953bc83c34cb427030815bf976cc7d1660f594 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:12:26 -0400 Subject: [PATCH 44/54] Fix alerts Count table title overflow wraps prematurely (#115364) (#115510) Co-authored-by: Pablo Machado --- .../components/alerts_kpis/alerts_count_panel/index.tsx | 2 +- .../components/alerts_kpis/alerts_histogram_panel/index.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 29324d186784e..c8d45ca67068a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -94,7 +94,7 @@ export const AlertsCountPanel = memo( {i18n.COUNT_TABLE_TITLE}} titleSize="s" hideSubtitle > diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index 0613c619d89b9..07fa81f27684c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -257,7 +257,11 @@ export const AlertsHistogramPanel = memo( }, [showLinkToAlerts, goToDetectionEngine, formatUrl]); const titleText = useMemo( - () => (onlyField == null ? title : i18n.TOP(onlyField)), + () => ( + + {onlyField == null ? title : i18n.TOP(onlyField)} + + ), [onlyField, title] ); From 65786cf25123381e5124c76687f7b0a15600b68e Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:17:37 -0400 Subject: [PATCH 45/54] [Security Solution][Endpoint] Change Trusted Apps to use `item_id` as its identifier and Enable Trusted Apps filtering by id in the UI (#115276) (#115509) * Add `item_id` to list of searchable fields * trusted apps api changes to use `item_id` instead of SO `id` * Change Policy Details Trusted App "View all details" action URL to show TA list filtered by the TA id Co-authored-by: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> --- .../list/policy_trusted_apps_list.test.tsx | 2 +- .../list/policy_trusted_apps_list.tsx | 2 +- .../pages/trusted_apps/constants.ts | 1 + .../routes/trusted_apps/handlers.test.ts | 5 +- .../endpoint/routes/trusted_apps/mapping.ts | 2 +- .../routes/trusted_apps/service.test.ts | 24 +++++- .../endpoint/routes/trusted_apps/service.ts | 77 +++++++++++++------ 7 files changed, 84 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index a8d3cc1505463..b5bfc16db2899 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -207,7 +207,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { expect(appTestContext.coreStart.application.navigateToApp).toHaveBeenCalledWith( APP_ID, expect.objectContaining({ - path: '/administration/trusted_apps?show=edit&id=89f72d8a-05b5-4350-8cad-0dc3661d6e67', + path: '/administration/trusted_apps?filter=89f72d8a-05b5-4350-8cad-0dc3661d6e67', }) ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index f6afd9d502486..7b1f8753831c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -113,7 +113,7 @@ export const PolicyTrustedAppsList = memo( for (const trustedApp of trustedAppItems) { const isGlobal = trustedApp.effectScope.type === 'global'; - const viewUrlPath = getTrustedAppsListPath({ id: trustedApp.id, show: 'edit' }); + const viewUrlPath = getTrustedAppsListPath({ filter: trustedApp.id }); const assignedPoliciesMenuItems: ArtifactEntryCollapsibleCardProps['policies'] = trustedApp.effectScope.type === 'global' ? undefined diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts index 0602ae18c1408..beefb8587d787 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/constants.ts @@ -8,6 +8,7 @@ export const SEARCHABLE_FIELDS: Readonly = [ `name`, `description`, + 'item_id', `entries.value`, `entries.entries.value`, ]; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts index 547c1f6a2e5ff..614ad4fb548ea 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/handlers.test.ts @@ -110,7 +110,7 @@ const Gold = licenseMock.createLicense({ license: { type: 'gold', mode: 'gold' } const packagePolicyClient = createPackagePolicyServiceMock() as jest.Mocked; -describe('handlers', () => { +describe('TrustedApps API Handlers', () => { beforeEach(() => { packagePolicyClient.getByIDs.mockReset(); }); @@ -195,6 +195,7 @@ describe('handlers', () => { const mockResponse = httpServerMock.createResponseFactory(); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await deleteTrustedAppHandler( createHandlerContextMock(), @@ -582,7 +583,7 @@ describe('handlers', () => { }); it('should return 404 if trusted app does not exist', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await updateHandler( createHandlerContextMock(), diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts index 2c085c14db009..08c1a3a809d4a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/mapping.ts @@ -122,7 +122,7 @@ export const exceptionListItemToTrustedApp = ( const grouped = entriesToConditionEntriesMap(exceptionListItem.entries); return { - id: exceptionListItem.id, + id: exceptionListItem.item_id, version: exceptionListItem._version || '', name: exceptionListItem.name, description: exceptionListItem.description, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts index dce84df735929..c57416ff1c974 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.test.ts @@ -85,9 +85,10 @@ const TRUSTED_APP: TrustedApp = { ], }; -describe('service', () => { +describe('TrustedApps service', () => { beforeEach(() => { exceptionsListClient.deleteExceptionListItem.mockReset(); + exceptionsListClient.getExceptionListItem.mockReset(); exceptionsListClient.createExceptionListItem.mockReset(); exceptionsListClient.findExceptionListItem.mockReset(); exceptionsListClient.createTrustedAppsList.mockReset(); @@ -96,6 +97,7 @@ describe('service', () => { describe('deleteTrustedApp', () => { it('should delete existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(EXCEPTION_LIST_ITEM); expect(await deleteTrustedApp(exceptionsListClient, { id: '123' })).toBeUndefined(); @@ -107,6 +109,7 @@ describe('service', () => { }); it('should throw for non existing trusted app', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); exceptionsListClient.deleteExceptionListItem.mockResolvedValue(null); await expect(deleteTrustedApp(exceptionsListClient, { id: '123' })).rejects.toBeInstanceOf( @@ -393,7 +396,7 @@ describe('service', () => { }); it('should throw a Not Found error if trusted app is not found prior to making update', async () => { - exceptionsListClient.getExceptionListItem.mockResolvedValueOnce(null); + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); await expect( updateTrustedApp( exceptionsListClient, @@ -489,5 +492,22 @@ describe('service', () => { TrustedAppNotFoundError ); }); + + it('should try to find trusted app by `itemId` and then by `id`', async () => { + exceptionsListClient.getExceptionListItem.mockResolvedValue(null); + await getTrustedApp(exceptionsListClient, '123').catch(() => Promise.resolve()); + + expect(exceptionsListClient.getExceptionListItem).toHaveBeenCalledTimes(2); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(1, { + itemId: '123', + id: undefined, + namespaceType: 'agnostic', + }); + expect(exceptionsListClient.getExceptionListItem).toHaveBeenNthCalledWith(2, { + itemId: undefined, + id: '123', + namespaceType: 'agnostic', + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts index 856a615c1ffa2..7a4b2372ece8f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/service.ts @@ -15,13 +15,13 @@ import { DeleteTrustedAppsRequestParams, GetOneTrustedAppResponse, GetTrustedAppsListRequest, - GetTrustedAppsSummaryResponse, GetTrustedAppsListResponse, + GetTrustedAppsSummaryRequest, + GetTrustedAppsSummaryResponse, PostTrustedAppCreateRequest, PostTrustedAppCreateResponse, PutTrustedAppUpdateRequest, PutTrustedAppUpdateResponse, - GetTrustedAppsSummaryRequest, TrustedApp, } from '../../../../common/endpoint/types'; @@ -33,8 +33,8 @@ import { } from './mapping'; import { TrustedAppNotFoundError, - TrustedAppVersionConflictError, TrustedAppPolicyNotExistsError, + TrustedAppVersionConflictError, } from './errors'; import { PackagePolicyServiceInterface } from '../../../../../fleet/server'; import { PackagePolicy } from '../../../../../fleet/common'; @@ -87,30 +87,61 @@ const isUserTryingToModifyEffectScopeWithoutPermissions = ( } }; -export const deleteTrustedApp = async ( +/** + * Attempts to first fine the ExceptionItem using `item_id` and if not found, then a second attempt wil be done + * against the Saved Object `id`. + * @param exceptionsListClient + * @param id + */ +export const findTrustedAppExceptionItemByIdOrItemId = async ( exceptionsListClient: ExceptionListClient, - { id }: DeleteTrustedAppsRequestParams -) => { - const exceptionListItem = await exceptionsListClient.deleteExceptionListItem({ - id, + id: string +): Promise => { + const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ + itemId: id, + id: undefined, + namespaceType: 'agnostic', + }); + + if (trustedAppExceptionItem) { + return trustedAppExceptionItem; + } + + return exceptionsListClient.getExceptionListItem({ itemId: undefined, + id, namespaceType: 'agnostic', }); +}; - if (!exceptionListItem) { +export const deleteTrustedApp = async ( + exceptionsListClient: ExceptionListClient, + { id }: DeleteTrustedAppsRequestParams +): Promise => { + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); + + if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } + + await exceptionsListClient.deleteExceptionListItem({ + id: trustedAppExceptionItem.id, + itemId: undefined, + namespaceType: 'agnostic', + }); }; export const getTrustedApp = async ( exceptionsListClient: ExceptionListClient, id: string ): Promise => { - const trustedAppExceptionItem = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const trustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); if (!trustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); @@ -189,19 +220,18 @@ export const updateTrustedApp = async ( updatedTrustedApp: PutTrustedAppUpdateRequest, isAtLeastPlatinum: boolean ): Promise => { - const currentTrustedApp = await exceptionsListClient.getExceptionListItem({ - itemId: '', - id, - namespaceType: 'agnostic', - }); + const currentTrustedAppExceptionItem = await findTrustedAppExceptionItemByIdOrItemId( + exceptionsListClient, + id + ); - if (!currentTrustedApp) { + if (!currentTrustedAppExceptionItem) { throw new TrustedAppNotFoundError(id); } if ( isUserTryingToModifyEffectScopeWithoutPermissions( - exceptionListItemToTrustedApp(currentTrustedApp), + exceptionListItemToTrustedApp(currentTrustedAppExceptionItem), updatedTrustedApp, isAtLeastPlatinum ) @@ -226,7 +256,10 @@ export const updateTrustedApp = async ( try { updatedTrustedAppExceptionItem = await exceptionsListClient.updateExceptionListItem( - updatedTrustedAppToUpdateExceptionListItemOptions(currentTrustedApp, updatedTrustedApp) + updatedTrustedAppToUpdateExceptionListItemOptions( + currentTrustedAppExceptionItem, + updatedTrustedApp + ) ); } catch (e) { if (e?.output?.statusCode === 409) { From 8fec45c45972495ff9f8e95b555509501c2eeadc Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 06:47:44 -0400 Subject: [PATCH 46/54] [Discover] Enable description for saved search modal (#114257) (#115514) * [Discover] enable description for saved search * [Discover] remove i18n translations for removed description * [Discover] apply Tim's suggestion * [Discover] update snapshot * [Discover] reorder top nav buttons in tests * [Description] fix description save action Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> --- .../components/top_nav/discover_topnav.test.tsx | 2 +- .../components/top_nav/get_top_nav_links.test.ts | 16 +++++++++------- .../main/components/top_nav/get_top_nav_links.ts | 4 +++- .../main/components/top_nav/on_save_search.tsx | 10 +++++----- .../embeddable/saved_search_embeddable.tsx | 4 ++++ .../plugins/translations/translations/ja-JP.json | 1 - .../plugins/translations/translations/zh-CN.json | 1 - 7 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx index 4b572f6e348b8..808346b53304c 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/discover_topnav.test.tsx @@ -42,7 +42,7 @@ describe('Discover topnav component', () => { const props = getProps(true); const component = shallowWithIntl(); const topMenuConfig = component.props().config.map((obj: TopNavMenuData) => obj.id); - expect(topMenuConfig).toEqual(['options', 'new', 'save', 'open', 'share', 'inspect']); + expect(topMenuConfig).toEqual(['options', 'new', 'open', 'share', 'inspect', 'save']); }); test('generated config of TopNavMenu config is correct when no discover save permissions are assigned', () => { diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts index d31ac6e0f2fea..20c5b9bae332d 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.test.ts @@ -53,13 +53,6 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "discoverNewButton", }, - Object { - "description": "Save Search", - "id": "save", - "label": "Save", - "run": [Function], - "testId": "discoverSaveButton", - }, Object { "description": "Open Saved Search", "id": "open", @@ -81,6 +74,15 @@ test('getTopNavLinks result', () => { "run": [Function], "testId": "openInspectorButton", }, + Object { + "description": "Save Search", + "emphasize": true, + "iconType": "save", + "id": "save", + "label": "Save", + "run": [Function], + "testId": "discoverSaveButton", + }, ] `); }); diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index 81be662470306..44d2999947f41 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -76,6 +76,8 @@ export const getTopNavLinks = ({ defaultMessage: 'Save Search', }), testId: 'discoverSaveButton', + iconType: 'save', + emphasize: true, run: () => onSaveSearch({ savedSearch, services, indexPattern, navigateTo, state }), }; @@ -153,9 +155,9 @@ export const getTopNavLinks = ({ return [ ...(services.capabilities.advancedSettings.save ? [options] : []), newSearch, - ...(services.capabilities.discover.save ? [saveSearch] : []), openSearch, shareSearch, inspectSearch, + ...(services.capabilities.discover.save ? [saveSearch] : []), ]; }; diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx index 18766b5df7f33..25b04e12c650a 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/on_save_search.tsx @@ -98,16 +98,19 @@ export async function onSaveSearch({ const onSave = async ({ newTitle, newCopyOnSave, + newDescription, isTitleDuplicateConfirmed, onTitleDuplicate, }: { newTitle: string; newCopyOnSave: boolean; + newDescription: string; isTitleDuplicateConfirmed: boolean; onTitleDuplicate: () => void; }) => { const currentTitle = savedSearch.title; savedSearch.title = newTitle; + savedSearch.description = newDescription; const saveOptions: SaveSavedSearchOptions = { onTitleDuplicate, copyOnSave: newCopyOnSave, @@ -136,14 +139,11 @@ export async function onSaveSearch({ onClose={() => {}} title={savedSearch.title ?? ''} showCopyOnSave={!!savedSearch.id} + description={savedSearch.description} objectType={i18n.translate('discover.localMenu.saveSaveSearchObjectType', { defaultMessage: 'search', })} - description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { - defaultMessage: - 'Save your Discover search so you can use it in visualizations and dashboards', - })} - showDescription={false} + showDescription={true} /> ); showSaveModal(saveModal, services.core.i18n.Context); diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index 8849806cf5959..89c47559d7b4c 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -402,6 +402,10 @@ export class SavedSearchEmbeddable return this.inspectorAdapters; } + public getDescription() { + return this.savedSearch.description; + } + public destroy() { super.destroy(); if (this.searchProps) { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 113eae5d08e07..f909a03909e3f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -2534,7 +2534,6 @@ "discover.localMenu.openSavedSearchDescription": "保存された検索を開きます", "discover.localMenu.openTitle": "開く", "discover.localMenu.optionsDescription": "オプション", - "discover.localMenu.saveSaveSearchDescription": "ビジュアライゼーションとダッシュボードで使用できるように Discover の検索を保存します", "discover.localMenu.saveSaveSearchObjectType": "検索", "discover.localMenu.saveSearchDescription": "検索を保存します", "discover.localMenu.saveTitle": "保存", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 15933599699eb..4de407cf8e464 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -2560,7 +2560,6 @@ "discover.localMenu.openSavedSearchDescription": "打开已保存搜索", "discover.localMenu.openTitle": "打开", "discover.localMenu.optionsDescription": "选项", - "discover.localMenu.saveSaveSearchDescription": "保存您的 Discover 搜索,以便可以在可视化和仪表板中使用该搜索", "discover.localMenu.saveSaveSearchObjectType": "搜索", "discover.localMenu.saveSearchDescription": "保存搜索", "discover.localMenu.saveTitle": "保存", From 44e0e53877b16723cf792e2ebea0e345c6de0231 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 07:10:02 -0400 Subject: [PATCH 47/54] [Security Solution][Rules] Halt Indicator Match execution after interval has passed (#115288) (#115517) * Throw an error to stop execution if IM rule has exceeded its interval * Extract and unit test our timeout validation * Add integration test around timeout behavior Configures a very slow rule to trigger a timeout and assert the corresponding failure. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Ryland Herrick --- .../threat_mapping/create_threat_signals.ts | 6 ++- .../signals/threat_mapping/utils.test.ts | 23 ++++++++++ .../signals/threat_mapping/utils.ts | 21 +++++++++ .../tests/create_threat_matching.ts | 46 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts index 169a820392a6e..677a2028acdf7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signals.ts @@ -11,7 +11,7 @@ import { getThreatList, getThreatListCount } from './get_threat_list'; import { CreateThreatSignalsOptions } from './types'; import { createThreatSignal } from './create_threat_signal'; import { SearchAfterAndBulkCreateReturnType } from '../types'; -import { combineConcurrentResults } from './utils'; +import { buildExecutionIntervalValidator, combineConcurrentResults } from './utils'; import { buildThreatEnrichment } from './build_threat_enrichment'; export const createThreatSignals = async ({ @@ -46,6 +46,9 @@ export const createThreatSignals = async ({ const params = ruleSO.attributes.params; logger.debug(buildRuleMessage('Indicator matching rule starting')); const perPage = concurrentSearches * itemsPerSearch; + const verifyExecutionCanProceed = buildExecutionIntervalValidator( + ruleSO.attributes.schedule.interval + ); let results: SearchAfterAndBulkCreateReturnType = { success: true, @@ -99,6 +102,7 @@ export const createThreatSignals = async ({ }); while (threatList.hits.hits.length !== 0) { + verifyExecutionCanProceed(); const chunks = chunk(itemsPerSearch, threatList.hits.hits); logger.debug(buildRuleMessage(`${chunks.length} concurrent indicator searches are starting.`)); const concurrentSearchesPerformed = chunks.map>( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts index ec826b44023f6..f029b02127b08 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.test.ts @@ -10,6 +10,7 @@ import { sampleSignalHit } from '../__mocks__/es_results'; import { ThreatMatchNamedQuery } from './types'; import { + buildExecutionIntervalValidator, calculateAdditiveMax, calculateMax, calculateMaxLookBack, @@ -712,4 +713,26 @@ describe('utils', () => { }); }); }); + + describe('buildExecutionIntervalValidator', () => { + it('succeeds if the validator is called within the specified interval', () => { + const validator = buildExecutionIntervalValidator('1m'); + expect(() => validator()).not.toThrowError(); + }); + + it('throws an error if the validator is called after the specified interval', async () => { + const validator = buildExecutionIntervalValidator('1s'); + + await new Promise((r) => setTimeout(r, 1001)); + expect(() => validator()).toThrowError( + 'Current rule execution has exceeded its allotted interval (1s) and has been stopped.' + ); + }); + + it('throws an error if the interval cannot be parsed', () => { + expect(() => buildExecutionIntervalValidator('badString')).toThrowError( + 'Unable to parse rule interval (badString); stopping rule execution since allotted duration is undefined' + ); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts index 4d9fda43f032e..99f6609faec91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/utils.ts @@ -5,7 +5,10 @@ * 2.0. */ +import moment from 'moment'; + import { SearchAfterAndBulkCreateReturnType, SignalSourceHit } from '../types'; +import { parseInterval } from '../utils'; import { ThreatMatchNamedQuery } from './types'; /** @@ -146,3 +149,21 @@ export const decodeThreatMatchNamedQuery = (encoded: string): ThreatMatchNamedQu export const extractNamedQueries = (hit: SignalSourceHit): ThreatMatchNamedQuery[] => hit.matched_queries?.map((match) => decodeThreatMatchNamedQuery(match)) ?? []; + +export const buildExecutionIntervalValidator: (interval: string) => () => void = (interval) => { + const intervalDuration = parseInterval(interval); + + if (intervalDuration == null) { + throw new Error( + `Unable to parse rule interval (${interval}); stopping rule execution since allotted duration is undefined.` + ); + } + + const executionEnd = moment().add(intervalDuration); + return () => { + if (moment().isAfter(executionEnd)) { + const message = `Current rule execution has exceeded its allotted interval (${interval}) and has been stopped.`; + throw new Error(message); + } + }; +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index 0aad3c699805a..223529fce54f6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -411,6 +411,52 @@ export default ({ getService }: FtrProviderContext) => { expect(signalsOpen.hits.hits.length).equal(0); }); + describe('timeout behavior', () => { + it('will return an error if a rule execution exceeds the rule interval', async () => { + const rule: CreateRulesSchema = { + description: 'Detecting root and admin users', + name: 'Query with a short interval', + severity: 'high', + index: ['auditbeat-*'], + type: 'threat_match', + risk_score: 55, + language: 'kuery', + rule_id: 'rule-1', + from: '1900-01-01T00:00:00.000Z', + query: '*:*', + threat_query: '*:*', // broad query to take more time + threat_index: ['auditbeat-*'], // We use auditbeat as both the matching index and the threat list for simplicity + threat_mapping: [ + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_filters: [], + concurrent_searches: 1, + interval: '1s', // short interval + items_per_search: 1, // iterate only 1 threat item per loop to ensure we're slow + }; + + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id, 'failed'); + + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_find_statuses`) + .set('kbn-xsrf', 'true') + .send({ ids: [id] }) + .expect(200); + expect(body[id].current_status.last_failure_message).to.contain( + 'execution has exceeded its allotted interval' + ); + }); + }); + describe('indicator enrichment', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/filebeat/threat_intel'); From 0c86129e4a21838983b23bdbc778d2f20d2751c0 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 07:37:34 -0400 Subject: [PATCH 48/54] [Security Solution][Detections] Hide building block rules in "Security/Overview" (#105611) (#115521) * Hide building block rules in "Security/Overview" * Add Cypress tests for alerts generated by building block rules Co-authored-by: Dmitry Shevchenko Co-authored-by: Georgii Gorbachev Co-authored-by: Dmitry Shevchenko --- .../building_block_alerts.spec.ts | 40 +++++++++++++++++++ .../security_solution/cypress/objects/rule.ts | 20 ++++++++++ .../cypress/screens/overview.ts | 2 + .../cypress/tasks/api_calls/rules.ts | 1 + .../components/signals_by_category/index.tsx | 15 +++++-- .../use_filters_for_signals_by_category.ts | 37 +++++++++++++++++ 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts create mode 100644 x-pack/plugins/security_solution/public/overview/components/signals_by_category/use_filters_for_signals_by_category.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts new file mode 100644 index 0000000000000..262ffe8163e57 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/building_block_alerts.spec.ts @@ -0,0 +1,40 @@ +/* + * 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 { getBuildingBlockRule } from '../../objects/rule'; +import { OVERVIEW_ALERTS_HISTOGRAM } from '../../screens/overview'; +import { OVERVIEW } from '../../screens/security_header'; +import { goToRuleDetails } from '../../tasks/alerts_detection_rules'; +import { createCustomRuleActivated } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { waitForAlertsToPopulate, waitForTheRuleToBeExecuted } from '../../tasks/create_new_rule'; +import { loginAndWaitForPage } from '../../tasks/login'; +import { navigateFromHeaderTo } from '../../tasks/security_header'; +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; + +const EXPECTED_NUMBER_OF_ALERTS = 16; + +describe('Alerts generated by building block rules', () => { + beforeEach(() => { + cleanKibana(); + }); + + it('Alerts should be visible on the Rule Detail page and not visible on the Overview page', () => { + createCustomRuleActivated(getBuildingBlockRule()); + loginAndWaitForPage(DETECTIONS_RULE_MANAGEMENT_URL); + goToRuleDetails(); + waitForTheRuleToBeExecuted(); + + // Check that generated events are visible on the Details page + waitForAlertsToPopulate(EXPECTED_NUMBER_OF_ALERTS); + + navigateFromHeaderTo(OVERVIEW); + + // Check that generated events are hidden on the Overview page + cy.get(OVERVIEW_ALERTS_HISTOGRAM).should('contain.text', 'No data to display'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 4b061865d632b..27973854097db 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -58,6 +58,7 @@ export interface CustomRule { lookBack: Interval; timeline: CompleteTimeline; maxSignals: number; + buildingBlockType?: string; } export interface ThresholdRule extends CustomRule { @@ -188,6 +189,25 @@ export const getNewRule = (): CustomRule => ({ maxSignals: 100, }); +export const getBuildingBlockRule = (): CustomRule => ({ + customQuery: 'host.name: *', + index: getIndexPatterns(), + name: 'Building Block Rule Test', + description: 'The new rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [getMitre1(), getMitre2()], + note: '# test markdown', + runsEvery: getRunsEvery(), + lookBack: getLookBack(), + timeline: getTimeline(), + maxSignals: 100, + buildingBlockType: 'default', +}); + export const getUnmappedRule = (): CustomRule => ({ customQuery: '*:*', index: ['unmapped*'], diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index 1376a39e5ee79..1945b7e3ce3e7 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -166,3 +166,5 @@ export const OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON = export const OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT = `${OVERVIEW_RISKY_HOSTS_LINKS} [data-test-subj="header-panel-subtitle"]`; export const OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON = '[data-test-subj="risky-hosts-enable-module-button"]'; + +export const OVERVIEW_ALERTS_HISTOGRAM = '[data-test-subj="alerts-histogram-panel"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 04ff0fcabc081..fd2838e5b3caa 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -114,6 +114,7 @@ export const createCustomRuleActivated = ( enabled: true, tags: ['rule1'], max_signals: maxSignals, + building_block_type: rule.buildingBlockType, }, headers: { 'kbn-xsrf': 'cypress-creds' }, failOnStatusCode: false, diff --git a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx index 321e6d00b5301..cbeb1464e1b41 100644 --- a/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/signals_by_category/index.tsx @@ -7,19 +7,24 @@ import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; +import { Filter, Query } from '@kbn/es-query'; import { AlertsHistogramPanel } from '../../../detections/components/alerts_kpis/alerts_histogram_panel'; import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { Filter, Query } from '../../../../../../../src/plugins/data/public'; + import { InputsModelId } from '../../../common/store/inputs/constants'; -import * as i18n from '../../pages/translations'; import { UpdateDateRange } from '../../../common/components/charts/common'; + import { AlertsStackByField } from '../../../detections/components/alerts_kpis/common/types'; +import * as i18n from '../../pages/translations'; + +import { useFiltersForSignalsByCategory } from './use_filters_for_signals_by_category'; + interface Props { combinedQueries?: string; - filters?: Filter[]; + filters: Filter[]; headerChildren?: React.ReactNode; /** Override all defaults, and only display this field */ onlyField?: AlertsStackByField; @@ -43,6 +48,8 @@ const SignalsByCategoryComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { signalIndexName } = useSignalIndex(); + const filtersForSignalsByCategory = useFiltersForSignalsByCategory(filters); + const updateDateRangeCallback = useCallback( ({ x }) => { if (!x) { @@ -63,7 +70,7 @@ const SignalsByCategoryComponent: React.FC = ({ return ( { + // TODO: Once we are past experimental phase this code should be removed + const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); + + const resultingFilters = useMemo( + () => [ + ...baseFilters, + ...(ruleRegistryEnabled + ? buildShowBuildingBlockFilterRuleRegistry(SHOW_BUILDING_BLOCK_ALERTS) // TODO: Once we are past experimental phase this code should be removed + : buildShowBuildingBlockFilter(SHOW_BUILDING_BLOCK_ALERTS)), + ], + [baseFilters, ruleRegistryEnabled] + ); + + return resultingFilters; +}; From f54724dc2b9f4597b137054864db3e63e6402144 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 07:37:47 -0400 Subject: [PATCH 49/54] [Unified Integrations] Clean up empty states, tutorial links and routing to prefer unified integrations (#114911) (#115493) Cleans up the integrations view and redirects all links to the integration manager. Co-authored-by: Caroline Horn <549577+cchaos@users.noreply.github.com> --- .../chrome/ui/header/collapsible_nav.tsx | 2 +- .../__snapshots__/add_data.test.tsx.snap | 16 +- .../components/add_data/add_data.test.tsx | 4 +- .../components/add_data/add_data.tsx | 152 +++++++++--------- .../components/sample_data/index.tsx | 4 +- .../components/tutorial_directory.js | 56 +------ .../public/application/components/welcome.tsx | 3 +- src/plugins/home/public/index.ts | 1 - src/plugins/home/public/services/index.ts | 1 - .../home/public/services/tutorials/index.ts | 1 - .../tutorials/tutorial_service.mock.ts | 1 - .../tutorials/tutorial_service.test.tsx | 32 ---- .../services/tutorials/tutorial_service.ts | 18 --- .../empty_index_list_prompt.tsx | 2 +- .../__snapshots__/overview.test.tsx.snap | 110 +++---------- .../public/components/overview/overview.tsx | 12 +- .../public/assets/elastic_beats_card_dark.svg | 1 - .../assets/elastic_beats_card_light.svg | 1 - .../__snapshots__/no_data_page.test.tsx.snap | 4 +- .../elastic_agent_card.test.tsx.snap | 55 ++++++- .../elastic_beats_card.test.tsx.snap | 70 -------- .../no_data_card/elastic_agent_card.test.tsx | 10 +- .../no_data_card/elastic_agent_card.tsx | 44 ++++- .../no_data_card/elastic_beats_card.test.tsx | 45 ------ .../no_data_card/elastic_beats_card.tsx | 66 -------- .../no_data_page/no_data_card/index.ts | 1 - .../no_data_page/no_data_page.tsx | 14 +- .../components/app/RumDashboard/RumHome.tsx | 8 +- .../routing/templates/no_data_config.ts | 10 +- .../epm/components/package_list_grid.tsx | 2 +- .../components/home_integration/index.tsx | 8 - .../tutorial_directory_header_link.tsx | 16 +- .../tutorial_directory_notice.tsx | 147 ----------------- x-pack/plugins/fleet/public/plugin.ts | 7 +- .../infra/public/pages/logs/page_content.tsx | 2 +- .../infra/public/pages/logs/page_template.tsx | 6 +- .../logs/stream/page_no_indices_content.tsx | 4 +- .../infra/public/pages/metrics/index.tsx | 4 +- .../metric_detail/components/invalid_node.tsx | 4 +- .../public/pages/metrics/page_template.tsx | 9 +- .../components/app/header/header_menu.tsx | 2 +- .../public/utils/no_data_config.ts | 7 +- .../security_solution/common/constants.ts | 2 +- .../components/overview_empty/index.test.tsx | 12 +- .../components/overview_empty/index.tsx | 50 ++---- .../translations/translations/ja-JP.json | 11 -- .../translations/translations/zh-CN.json | 11 -- x-pack/test/accessibility/apps/home.ts | 27 ---- 48 files changed, 292 insertions(+), 783 deletions(-) delete mode 100644 src/plugins/kibana_react/public/assets/elastic_beats_card_dark.svg delete mode 100644 src/plugins/kibana_react/public/assets/elastic_beats_card_light.svg delete mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap delete mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.test.tsx delete mode 100644 src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.tsx delete mode 100644 x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_notice.tsx diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index ad590865b9e14..ccc0e17b655b1 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -362,7 +362,7 @@ export function CollapsibleNav({ iconType="plusInCircleFilled" > {i18n.translate('core.ui.primaryNav.addData', { - defaultMessage: 'Add data', + defaultMessage: 'Add integrations', })}
diff --git a/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap index 26b5697f008b6..de6beab31247a 100644 --- a/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap +++ b/src/plugins/home/public/application/components/add_data/__snapshots__/add_data.test.tsx.snap @@ -17,7 +17,7 @@ exports[`AddData render 1`] = ` id="homDataAdd__title" > @@ -43,17 +43,25 @@ exports[`AddData render 1`] = ` grow={false} > diff --git a/src/plugins/home/public/application/components/add_data/add_data.test.tsx b/src/plugins/home/public/application/components/add_data/add_data.test.tsx index 4018ae67c19ee..3aa51f89c7d67 100644 --- a/src/plugins/home/public/application/components/add_data/add_data.test.tsx +++ b/src/plugins/home/public/application/components/add_data/add_data.test.tsx @@ -27,7 +27,9 @@ beforeEach(() => { jest.clearAllMocks(); }); -const applicationStartMock = {} as unknown as ApplicationStart; +const applicationStartMock = { + capabilities: { navLinks: { integrations: true } }, +} as unknown as ApplicationStart; const addBasePathMock = jest.fn((path: string) => (path ? path : 'path')); diff --git a/src/plugins/home/public/application/components/add_data/add_data.tsx b/src/plugins/home/public/application/components/add_data/add_data.tsx index 97ba28a04a07e..50d6079dd8df3 100644 --- a/src/plugins/home/public/application/components/add_data/add_data.tsx +++ b/src/plugins/home/public/application/components/add_data/add_data.tsx @@ -22,8 +22,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { METRIC_TYPE } from '@kbn/analytics'; import { ApplicationStart } from 'kibana/public'; import { createAppNavigationHandler } from '../app_navigation_handler'; -// @ts-expect-error untyped component -import { Synopsis } from '../synopsis'; import { getServices } from '../../kibana_services'; import { RedirectAppLinks } from '../../../../../kibana_react/public'; @@ -35,87 +33,91 @@ interface Props { export const AddData: FC = ({ addBasePath, application, isDarkMode }) => { const { trackUiMetric } = getServices(); + const canAccessIntegrations = application.capabilities.navLinks.integrations; + if (canAccessIntegrations) { + return ( + <> +
+ + + +

+ +

+
- return ( - <> -
- - - -

- -

-
+ - + +

+ +

+
- -

- -

-
+ - + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + { + trackUiMetric(METRIC_TYPE.CLICK, 'home_tutorial_directory'); + createAppNavigationHandler('/app/integrations/browse')(event); + }} + > + + + + - - - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - { - trackUiMetric(METRIC_TYPE.CLICK, 'home_tutorial_directory'); - createAppNavigationHandler('/app/home#/tutorial_directory')(event); - }} + + - - - - - - - - - - -
+ + +
+ - - - - -
+ + + +
+
- - - ); + + + ); + } else { + return null; + } }; diff --git a/src/plugins/home/public/application/components/sample_data/index.tsx b/src/plugins/home/public/application/components/sample_data/index.tsx index d6b9328f57e9b..b65fbb5d002b0 100644 --- a/src/plugins/home/public/application/components/sample_data/index.tsx +++ b/src/plugins/home/public/application/components/sample_data/index.tsx @@ -40,7 +40,7 @@ export function SampleDataCard({ urlBasePath, onDecline, onConfirm }: Props) { image={cardGraphicURL} textAlign="left" title={ - + } description={ - + { - const notices = getServices().tutorialService.getDirectoryNotices(); - return notices.length ? ( - - {notices.map((DirectoryNotice, index) => ( - - - - ))} - - ) : null; - }; - renderHeaderLinks = () => { const headerLinks = getServices().tutorialService.getDirectoryHeaderLinks(); return headerLinks.length ? ( @@ -245,7 +203,6 @@ class TutorialDirectoryUi extends React.Component { render() { const headerLinks = this.renderHeaderLinks(); const tabs = this.getTabs(); - const notices = this.renderNotices(); return ( + ), tabs, rightSideItems: headerLinks ? [headerLinks] : [], }} > - {notices && ( - <> - {notices} - - - )} {this.renderTabContent()} ); diff --git a/src/plugins/home/public/application/components/welcome.tsx b/src/plugins/home/public/application/components/welcome.tsx index ca7e6874c75c2..03dff22c7b33f 100644 --- a/src/plugins/home/public/application/components/welcome.tsx +++ b/src/plugins/home/public/application/components/welcome.tsx @@ -48,8 +48,7 @@ export class Welcome extends React.Component { }; private redirecToAddData() { - const path = this.services.addBasePath('#/tutorial_directory'); - window.location.href = path; + this.services.application.navigateToApp('integrations', { path: '/browse' }); } private onSampleDataDecline = () => { diff --git a/src/plugins/home/public/index.ts b/src/plugins/home/public/index.ts index dd02bf65dd8b0..7abaf5d19f008 100644 --- a/src/plugins/home/public/index.ts +++ b/src/plugins/home/public/index.ts @@ -23,7 +23,6 @@ export type { FeatureCatalogueSolution, Environment, TutorialVariables, - TutorialDirectoryNoticeComponent, TutorialDirectoryHeaderLinkComponent, TutorialModuleNoticeComponent, } from './services'; diff --git a/src/plugins/home/public/services/index.ts b/src/plugins/home/public/services/index.ts index 65913df6310b1..2ee68a9eef0c2 100644 --- a/src/plugins/home/public/services/index.ts +++ b/src/plugins/home/public/services/index.ts @@ -22,7 +22,6 @@ export { TutorialService } from './tutorials'; export type { TutorialVariables, TutorialServiceSetup, - TutorialDirectoryNoticeComponent, TutorialDirectoryHeaderLinkComponent, TutorialModuleNoticeComponent, } from './tutorials'; diff --git a/src/plugins/home/public/services/tutorials/index.ts b/src/plugins/home/public/services/tutorials/index.ts index 8de12c31249d8..e007a5ea4d552 100644 --- a/src/plugins/home/public/services/tutorials/index.ts +++ b/src/plugins/home/public/services/tutorials/index.ts @@ -11,7 +11,6 @@ export { TutorialService } from './tutorial_service'; export type { TutorialVariables, TutorialServiceSetup, - TutorialDirectoryNoticeComponent, TutorialDirectoryHeaderLinkComponent, TutorialModuleNoticeComponent, } from './tutorial_service'; diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts index 0c109d61912ca..ab38a32a1a5b3 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.mock.ts @@ -25,7 +25,6 @@ const createMock = (): jest.Mocked> => { const service = { setup: jest.fn(), getVariables: jest.fn(() => ({})), - getDirectoryNotices: jest.fn(() => []), getDirectoryHeaderLinks: jest.fn(() => []), getModuleNotices: jest.fn(() => []), getCustomStatusCheck: jest.fn(), diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx index a88cf526e3716..b90165aafb45f 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx +++ b/src/plugins/home/public/services/tutorials/tutorial_service.test.tsx @@ -27,22 +27,6 @@ describe('TutorialService', () => { }).toThrow(); }); - test('allows multiple register directory notice calls', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.registerDirectoryNotice('abc', () =>
); - setup.registerDirectoryNotice('def', () => ); - }).not.toThrow(); - }); - - test('throws when same directory notice is registered twice', () => { - const setup = new TutorialService().setup(); - expect(() => { - setup.registerDirectoryNotice('abc', () =>
); - setup.registerDirectoryNotice('abc', () => ); - }).toThrow(); - }); - test('allows multiple register directory header link calls', () => { const setup = new TutorialService().setup(); expect(() => { @@ -91,22 +75,6 @@ describe('TutorialService', () => { }); }); - describe('getDirectoryNotices', () => { - test('returns empty array', () => { - const service = new TutorialService(); - expect(service.getDirectoryNotices()).toEqual([]); - }); - - test('returns last state of register calls', () => { - const service = new TutorialService(); - const setup = service.setup(); - const notices = [() =>
, () => ]; - setup.registerDirectoryNotice('abc', notices[0]); - setup.registerDirectoryNotice('def', notices[1]); - expect(service.getDirectoryNotices()).toEqual(notices); - }); - }); - describe('getDirectoryHeaderLinks', () => { test('returns empty array', () => { const service = new TutorialService(); diff --git a/src/plugins/home/public/services/tutorials/tutorial_service.ts b/src/plugins/home/public/services/tutorials/tutorial_service.ts index 839b0702a499e..81b6bbe72e3e9 100644 --- a/src/plugins/home/public/services/tutorials/tutorial_service.ts +++ b/src/plugins/home/public/services/tutorials/tutorial_service.ts @@ -11,9 +11,6 @@ import React from 'react'; /** @public */ export type TutorialVariables = Partial>; -/** @public */ -export type TutorialDirectoryNoticeComponent = React.FC; - /** @public */ export type TutorialDirectoryHeaderLinkComponent = React.FC; @@ -27,7 +24,6 @@ type CustomComponent = () => Promise; export class TutorialService { private tutorialVariables: TutorialVariables = {}; - private tutorialDirectoryNotices: { [key: string]: TutorialDirectoryNoticeComponent } = {}; private tutorialDirectoryHeaderLinks: { [key: string]: TutorialDirectoryHeaderLinkComponent; } = {}; @@ -47,16 +43,6 @@ export class TutorialService { this.tutorialVariables[key] = value; }, - /** - * Registers a component that will be rendered at the top of tutorial directory page. - */ - registerDirectoryNotice: (id: string, component: TutorialDirectoryNoticeComponent) => { - if (this.tutorialDirectoryNotices[id]) { - throw new Error(`directory notice ${id} already set`); - } - this.tutorialDirectoryNotices[id] = component; - }, - /** * Registers a component that will be rendered next to tutorial directory title/header area. */ @@ -94,10 +80,6 @@ export class TutorialService { return this.tutorialVariables; } - public getDirectoryNotices() { - return Object.values(this.tutorialDirectoryNotices); - } - public getDirectoryHeaderLinks() { return Object.values(this.tutorialDirectoryHeaderLinks); } diff --git a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx index 1331eb9b7c4ac..d00f9e2368e21 100644 --- a/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx +++ b/src/plugins/index_pattern_editor/public/components/empty_prompts/empty_index_list_prompt/empty_index_list_prompt.tsx @@ -91,7 +91,7 @@ export const EmptyIndexListPrompt = ({ { - navigateToApp('home', { path: '#/tutorial_directory' }); + navigateToApp('home', { path: '/app/integrations/browse' }); closeFlyout(); }} icon={} diff --git a/src/plugins/kibana_overview/public/components/overview/__snapshots__/overview.test.tsx.snap b/src/plugins/kibana_overview/public/components/overview/__snapshots__/overview.test.tsx.snap index 6da2f95fa394d..babcab15a4974 100644 --- a/src/plugins/kibana_overview/public/components/overview/__snapshots__/overview.test.tsx.snap +++ b/src/plugins/kibana_overview/public/components/overview/__snapshots__/overview.test.tsx.snap @@ -226,10 +226,7 @@ exports[`Overview render 1`] = ` [MockFunction] { "calls": Array [ Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -259,11 +256,7 @@ exports[`Overview render 1`] = ` "results": Array [ Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -533,10 +526,7 @@ exports[`Overview without features 1`] = ` [MockFunction] { "calls": Array [ Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -563,16 +553,10 @@ exports[`Overview without features 1`] = ` "/plugins/kibanaReact/assets/solutions_solution_4.svg", ], Array [ - "/app/home#/tutorial_directory", + "/app/integrations/browse", ], Array [ - "home#/tutorial_directory", - ], - Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -602,11 +586,7 @@ exports[`Overview without features 1`] = ` "results": Array [ Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -642,19 +622,11 @@ exports[`Overview without features 1`] = ` }, Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "/app/home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -801,10 +773,7 @@ exports[`Overview without solutions 1`] = ` [MockFunction] { "calls": Array [ Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -831,20 +800,13 @@ exports[`Overview without solutions 1`] = ` "/plugins/kibanaReact/assets/solutions_solution_4.svg", ], Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], ], "results": Array [ Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -880,11 +842,7 @@ exports[`Overview without solutions 1`] = ` }, Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, ], } @@ -898,10 +856,7 @@ exports[`Overview without solutions 1`] = ` [MockFunction] { "calls": Array [ Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -928,20 +883,13 @@ exports[`Overview without solutions 1`] = ` "/plugins/kibanaReact/assets/solutions_solution_4.svg", ], Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], ], "results": Array [ Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -977,11 +925,7 @@ exports[`Overview without solutions 1`] = ` }, Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, ], } @@ -1001,10 +945,7 @@ exports[`Overview without solutions 1`] = ` [MockFunction] { "calls": Array [ Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], Array [ "kibana_landing_page", @@ -1031,20 +972,13 @@ exports[`Overview without solutions 1`] = ` "/plugins/kibanaReact/assets/solutions_solution_4.svg", ], Array [ - "/app/home#/tutorial_directory", - ], - Array [ - "home#/tutorial_directory", + "/app/integrations/browse", ], ], "results": Array [ Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, Object { "type": "return", @@ -1080,11 +1014,7 @@ exports[`Overview without solutions 1`] = ` }, Object { "type": "return", - "value": "/app/home#/tutorial_directory", - }, - Object { - "type": "return", - "value": "home#/tutorial_directory", + "value": "/app/integrations/browse", }, ], } diff --git a/src/plugins/kibana_overview/public/components/overview/overview.tsx b/src/plugins/kibana_overview/public/components/overview/overview.tsx index 07769e2f3c474..6a0279bd12465 100644 --- a/src/plugins/kibana_overview/public/components/overview/overview.tsx +++ b/src/plugins/kibana_overview/public/components/overview/overview.tsx @@ -61,7 +61,7 @@ export const Overview: FC = ({ newsFetchResult, solutions, features }) => const IS_DARK_THEME = uiSettings.get('theme:darkMode'); // Home does not have a locator implemented, so hard-code it here. - const addDataHref = addBasePath('/app/home#/tutorial_directory'); + const addDataHref = addBasePath('/app/integrations/browse'); const devToolsHref = share.url.locators.get('CONSOLE_APP_LOCATOR')?.useUrl({}); const managementHref = share.url.locators .get('MANAGEMENT_APP_LOCATOR') @@ -86,8 +86,14 @@ export const Overview: FC = ({ newsFetchResult, solutions, features }) => }), logo: 'logoKibana', actions: { - beats: { - href: addBasePath(`home#/tutorial_directory`), + elasticAgent: { + title: i18n.translate('kibanaOverview.noDataConfig.title', { + defaultMessage: 'Add integrations', + }), + description: i18n.translate('kibanaOverview.noDataConfig.description', { + defaultMessage: + 'Use Elastic Agent or Beats to collect data and build out Analytics solutions.', + }), }, }, docsLink: docLinks.links.kibana, diff --git a/src/plugins/kibana_react/public/assets/elastic_beats_card_dark.svg b/src/plugins/kibana_react/public/assets/elastic_beats_card_dark.svg deleted file mode 100644 index 8652d8d921506..0000000000000 --- a/src/plugins/kibana_react/public/assets/elastic_beats_card_dark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/plugins/kibana_react/public/assets/elastic_beats_card_light.svg b/src/plugins/kibana_react/public/assets/elastic_beats_card_light.svg deleted file mode 100644 index f54786c1b950c..0000000000000 --- a/src/plugins/kibana_react/public/assets/elastic_beats_card_light.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap index d8bc5745ec8e5..8842a3c9f5842 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/__snapshots__/no_data_page.test.tsx.snap @@ -73,9 +73,9 @@ exports[`NoDataPage render 1`] = ` - diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap index 3f72ae5597a98..f66d05140b2e9 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_agent_card.test.tsx.snap @@ -13,7 +13,36 @@ exports[`ElasticAgentCard props button 1`] = ` href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add Elastic Agent" + title={ + + + Add Elastic Agent + + + } +/> +`; + +exports[`ElasticAgentCard props category 1`] = ` + + Add Elastic Agent + + } + href="/app/integrations/browse/custom" + image="/plugins/kibanaReact/assets/elastic_agent_card.svg" + paddingSize="l" + title={ + + + Add Elastic Agent + + + } /> `; @@ -30,7 +59,13 @@ exports[`ElasticAgentCard props href 1`] = ` href="#" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add Elastic Agent" + title={ + + + Add Elastic Agent + + + } /> `; @@ -48,7 +83,13 @@ exports[`ElasticAgentCard props recommended 1`] = ` href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add Elastic Agent" + title={ + + + Add Elastic Agent + + + } /> `; @@ -65,6 +106,12 @@ exports[`ElasticAgentCard renders 1`] = ` href="/app/integrations/browse" image="/plugins/kibanaReact/assets/elastic_agent_card.svg" paddingSize="l" - title="Add Elastic Agent" + title={ + + + Add Elastic Agent + + + } /> `; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap deleted file mode 100644 index af26f9e93ebac..0000000000000 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/__snapshots__/elastic_beats_card.test.tsx.snap +++ /dev/null @@ -1,70 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ElasticBeatsCard props button 1`] = ` - - Button - - } - href="/app/home#/tutorial_directory" - image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" - paddingSize="l" - title="Add data" -/> -`; - -exports[`ElasticBeatsCard props href 1`] = ` - - Button - - } - href="#" - image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" - paddingSize="l" - title="Add data" -/> -`; - -exports[`ElasticBeatsCard props recommended 1`] = ` - - Add data - - } - href="/app/home#/tutorial_directory" - image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" - paddingSize="l" - title="Add data" -/> -`; - -exports[`ElasticBeatsCard renders 1`] = ` - - Add data - - } - href="/app/home#/tutorial_directory" - image="/plugins/kibanaReact/assets/elastic_beats_card_light.svg" - paddingSize="l" - title="Add data" -/> -`; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx index 45cc32cae06d6..b971abf06a437 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.test.tsx @@ -14,7 +14,10 @@ jest.mock('../../../context', () => ({ ...jest.requireActual('../../../context'), useKibana: jest.fn().mockReturnValue({ services: { - http: { basePath: { prepend: jest.fn((path: string) => (path ? path : 'path')) } }, + http: { + basePath: { prepend: jest.fn((path: string) => (path ? path : 'path')) }, + }, + application: { capabilities: { navLinks: { integrations: true } } }, uiSettings: { get: jest.fn() }, }, }), @@ -41,5 +44,10 @@ describe('ElasticAgentCard', () => { const component = shallow(); expect(component).toMatchSnapshot(); }); + + test('category', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); }); }); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx index f071bd9fab25a..5a91e568471d1 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_agent_card.tsx @@ -9,7 +9,7 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; -import { EuiButton, EuiCard } from '@elastic/eui'; +import { EuiButton, EuiCard, EuiTextColor, EuiScreenReaderOnly } from '@elastic/eui'; import { useKibana } from '../../../context'; import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page'; @@ -27,13 +27,40 @@ export const ElasticAgentCard: FunctionComponent = ({ href, button, layout, + category, ...cardRest }) => { const { - services: { http }, + services: { http, application }, } = useKibana(); const addBasePath = http.basePath.prepend; - const basePathUrl = '/plugins/kibanaReact/assets/'; + const image = addBasePath(`/plugins/kibanaReact/assets/elastic_agent_card.svg`); + const canAccessFleet = application.capabilities.navLinks.integrations; + const hasCategory = category ? `/${category}` : ''; + + if (!canAccessFleet) { + return ( + + {i18n.translate('kibana-react.noDataPage.elasticAgentCard.noPermission.title', { + defaultMessage: `Contact your administrator`, + })} + + } + description={ + + {i18n.translate('kibana-react.noDataPage.elasticAgentCard.noPermission.description', { + defaultMessage: `This integration is not yet enabled. Your administrator has the required permissions to turn it on.`, + })} + + } + isDisabled + /> + ); + } const defaultCTAtitle = i18n.translate('kibana-react.noDataPage.elasticAgentCard.title', { defaultMessage: 'Add Elastic Agent', @@ -51,12 +78,17 @@ export const ElasticAgentCard: FunctionComponent = ({ return ( + {defaultCTAtitle} + + } description={i18n.translate('kibana-react.noDataPage.elasticAgentCard.description', { defaultMessage: `Use Elastic Agent for a simple, unified way to collect data from your machines.`, })} - image={addBasePath(`${basePathUrl}elastic_agent_card.svg`)} betaBadgeLabel={recommended ? NO_DATA_RECOMMENDED : undefined} footer={footer} layout={layout as 'vertical' | undefined} diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.test.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.test.tsx deleted file mode 100644 index 6ea41bf6b3e1f..0000000000000 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { shallow } from 'enzyme'; -import React from 'react'; -import { ElasticBeatsCard } from './elastic_beats_card'; - -jest.mock('../../../context', () => ({ - ...jest.requireActual('../../../context'), - useKibana: jest.fn().mockReturnValue({ - services: { - http: { basePath: { prepend: jest.fn((path: string) => (path ? path : 'path')) } }, - uiSettings: { get: jest.fn() }, - }, - }), -})); - -describe('ElasticBeatsCard', () => { - test('renders', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); - }); - - describe('props', () => { - test('recommended', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); - }); - - test('button', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); - }); - - test('href', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); - }); - }); -}); diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.tsx deleted file mode 100644 index 0372d12096489..0000000000000 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/elastic_beats_card.tsx +++ /dev/null @@ -1,66 +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 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import React, { FunctionComponent } from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'kibana/public'; -import { EuiButton, EuiCard } from '@elastic/eui'; -import { useKibana } from '../../../context'; -import { NoDataPageActions, NO_DATA_RECOMMENDED } from '../no_data_page'; - -export type ElasticBeatsCardProps = NoDataPageActions & { - solution: string; -}; - -export const ElasticBeatsCard: FunctionComponent = ({ - recommended, - title, - button, - href, - solution, // unused for now - layout, - ...cardRest -}) => { - const { - services: { http, uiSettings }, - } = useKibana(); - const addBasePath = http.basePath.prepend; - const basePathUrl = '/plugins/kibanaReact/assets/'; - const IS_DARK_THEME = uiSettings.get('theme:darkMode'); - - const defaultCTAtitle = i18n.translate('kibana-react.noDataPage.elasticBeatsCard.title', { - defaultMessage: 'Add data', - }); - - const footer = - typeof button !== 'string' && typeof button !== 'undefined' ? ( - button - ) : ( - // The href and/or onClick are attached to the whole Card, so the button is just for show. - // Do not add the behavior here too or else it will propogate through - {button || title || defaultCTAtitle} - ); - - return ( - - ); -}; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts index 3744239d9a472..e05d4d9675ca9 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_card/index.ts @@ -7,5 +7,4 @@ */ export * from './elastic_agent_card'; -export * from './elastic_beats_card'; export * from './no_data_card'; diff --git a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx index 56eb0f34617d6..b2d9ef6ca5008 100644 --- a/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx +++ b/src/plugins/kibana_react/public/page_template/no_data_page/no_data_page.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaPageTemplateProps } from '../page_template'; -import { ElasticAgentCard, ElasticBeatsCard, NoDataCard } from './no_data_card'; +import { ElasticAgentCard, NoDataCard } from './no_data_card'; import { KibanaPageTemplateSolutionNavAvatar } from '../solution_nav'; export const NO_DATA_PAGE_MAX_WIDTH = 950; @@ -55,6 +55,10 @@ export type NoDataPageActions = Partial & { * Remapping `onClick` to any element */ onClick?: MouseEventHandler; + /** + * Category to auto-select within Fleet + */ + category?: string; }; export type NoDataPageActionsProps = Record; @@ -107,18 +111,12 @@ export const NoDataPage: FunctionComponent = ({ const actionsKeys = Object.keys(sortedData); const renderActions = useMemo(() => { return Object.values(sortedData).map((action, i) => { - if (actionsKeys[i] === 'elasticAgent') { + if (actionsKeys[i] === 'elasticAgent' || actionsKeys[i] === 'beats') { return ( ); - } else if (actionsKeys[i] === 'beats') { - return ( - - - - ); } else { return ( ), discussForumLink: ( - + import('./tutorial_directory_notice')); -export const TutorialDirectoryNotice: TutorialDirectoryNoticeComponent = () => ( - }> - - -); - const TutorialDirectoryHeaderLinkLazy = React.lazy( () => import('./tutorial_directory_header_link') ); diff --git a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx index 074a1c40bdb19..18fdd875c7379 100644 --- a/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx +++ b/x-pack/plugins/fleet/public/components/home_integration/tutorial_directory_header_link.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { memo, useState, useEffect } from 'react'; +import React, { memo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty } from '@elastic/eui'; import type { TutorialDirectoryHeaderLinkComponent } from 'src/plugins/home/public'; @@ -13,25 +13,15 @@ import type { TutorialDirectoryHeaderLinkComponent } from 'src/plugins/home/publ import { RedirectAppLinks } from '../../../../../../src/plugins/kibana_react/public'; import { useLink, useCapabilities, useStartServices } from '../../hooks'; -import { tutorialDirectoryNoticeState$ } from './tutorial_directory_notice'; - const TutorialDirectoryHeaderLink: TutorialDirectoryHeaderLinkComponent = memo(() => { const { getHref } = useLink(); const { application } = useStartServices(); const { show: hasIngestManager } = useCapabilities(); - const [noticeState, setNoticeState] = useState({ + const [noticeState] = useState({ settingsDataLoaded: false, - hasSeenNotice: false, }); - useEffect(() => { - const subscription = tutorialDirectoryNoticeState$.subscribe((value) => setNoticeState(value)); - return () => { - subscription.unsubscribe(); - }; - }, []); - - return hasIngestManager && noticeState.settingsDataLoaded && noticeState.hasSeenNotice ? ( + return hasIngestManager && noticeState.settingsDataLoaded ? ( { - const { getHref } = useLink(); - const { application } = useStartServices(); - const { show: hasIngestManager } = useCapabilities(); - const { data: settingsData, isLoading } = useGetSettings(); - const [dismissedNotice, setDismissedNotice] = useState(false); - - const dismissNotice = useCallback(async () => { - setDismissedNotice(true); - await sendPutSettings({ - has_seen_add_data_notice: true, - }); - }, []); - - useEffect(() => { - tutorialDirectoryNoticeState$.next({ - settingsDataLoaded: !isLoading, - hasSeenNotice: Boolean(dismissedNotice || settingsData?.item?.has_seen_add_data_notice), - }); - }, [isLoading, settingsData, dismissedNotice]); - - const hasSeenNotice = - isLoading || settingsData?.item?.has_seen_add_data_notice || dismissedNotice; - - return hasIngestManager && !hasSeenNotice ? ( - <> - - - - ), - }} - /> - } - > -

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

- - -
- - - - - -
-
- -
- { - dismissNotice(); - }} - > - - -
-
-
-
- - - ) : null; -}); - -// Needed for React.lazy -// eslint-disable-next-line import/no-default-export -export default TutorialDirectoryNotice; diff --git a/x-pack/plugins/fleet/public/plugin.ts b/x-pack/plugins/fleet/public/plugin.ts index e1f263b0763e8..4a2a6900cc78c 100644 --- a/x-pack/plugins/fleet/public/plugin.ts +++ b/x-pack/plugins/fleet/public/plugin.ts @@ -44,11 +44,7 @@ import { CUSTOM_LOGS_INTEGRATION_NAME, INTEGRATIONS_BASE_PATH } from './constant import { licenseService } from './hooks'; import { setHttpClient } from './hooks/use_request'; import { createPackageSearchProvider } from './search_provider'; -import { - TutorialDirectoryNotice, - TutorialDirectoryHeaderLink, - TutorialModuleNotice, -} from './components/home_integration'; +import { TutorialDirectoryHeaderLink, TutorialModuleNotice } from './components/home_integration'; import { createExtensionRegistrationCallback } from './services/ui_extensions'; import type { UIExtensionRegistrationCallback, UIExtensionsStorage } from './types'; import { LazyCustomLogsAssetsExtension } from './lazy_custom_logs_assets_extension'; @@ -197,7 +193,6 @@ export class FleetPlugin implements Plugin { diff --git a/x-pack/plugins/infra/public/pages/logs/page_template.tsx b/x-pack/plugins/infra/public/pages/logs/page_template.tsx index 7ee60ab84bf25..6de13b495f0ba 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_template.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_template.tsx @@ -44,13 +44,13 @@ export const LogsPageTemplate: React.FC = ({ actions: { beats: { title: i18n.translate('xpack.infra.logs.noDataConfig.beatsCard.title', { - defaultMessage: 'Add logs with Beats', + defaultMessage: 'Add a logging integration', }), description: i18n.translate('xpack.infra.logs.noDataConfig.beatsCard.description', { defaultMessage: - 'Use Beats to send logs to Elasticsearch. We make it easy with modules for many popular systems and apps.', + 'Use the Elastic Agent or Beats to send logs to Elasticsearch. We make it easy with integrations for many popular systems and apps.', }), - href: basePath + `/app/home#/tutorial_directory/logging`, + href: basePath + `/app/integrations/browse`, }, }, docsLink: docLinks.links.observability.guide, diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx index bc3bc22f3f1b2..2259a8d3528af 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_no_indices_content.tsx @@ -22,8 +22,8 @@ export const LogsPageNoIndicesContent = () => { const canConfigureSource = application?.capabilities?.logs?.configureSource ? true : false; const tutorialLinkProps = useLinkProps({ - app: 'home', - hash: '/tutorial_directory/logging', + app: 'integrations', + hash: '/browse', }); return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index ae375dc504e7a..1a79cd996087d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -93,9 +93,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { diff --git a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx index 2a436eac30b2c..17e6382ce65cc 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metric_detail/components/invalid_node.tsx @@ -18,8 +18,8 @@ interface InvalidNodeErrorProps { export const InvalidNodeError: React.FunctionComponent = ({ nodeName }) => { const tutorialLinkProps = useLinkProps({ - app: 'home', - hash: '/tutorial_directory/metrics', + app: 'integrations', + hash: '/browse', }); return ( diff --git a/x-pack/plugins/infra/public/pages/metrics/page_template.tsx b/x-pack/plugins/infra/public/pages/metrics/page_template.tsx index 41ea12c280841..4da671283644d 100644 --- a/x-pack/plugins/infra/public/pages/metrics/page_template.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/page_template.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import { useKibanaContextForPlugin } from '../../hooks/use_kibana'; import type { LazyObservabilityPageTemplateProps } from '../../../../observability/public'; import { KibanaPageTemplateProps } from '../../../../../../src/plugins/kibana_react/public'; -import { useLinkProps } from '../../hooks/use_link_props'; interface MetricsPageTemplateProps extends LazyObservabilityPageTemplateProps { hasData?: boolean; @@ -30,11 +29,6 @@ export const MetricsPageTemplate: React.FC = ({ }, } = useKibanaContextForPlugin(); - const tutorialLinkProps = useLinkProps({ - app: 'home', - hash: '/tutorial_directory/metrics', - }); - const noDataConfig: KibanaPageTemplateProps['noDataConfig'] = hasData ? undefined : { @@ -44,13 +38,12 @@ export const MetricsPageTemplate: React.FC = ({ actions: { beats: { title: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.title', { - defaultMessage: 'Add metrics with Beats', + defaultMessage: 'Add a metrics integration', }), description: i18n.translate('xpack.infra.metrics.noDataConfig.beatsCard.description', { defaultMessage: 'Use Beats to send metrics data to Elasticsearch. We make it easy with modules for many popular systems and apps.', }), - ...tutorialLinkProps, }, }, docsLink: docLinks.links.observability.guide, diff --git a/x-pack/plugins/observability/public/components/app/header/header_menu.tsx b/x-pack/plugins/observability/public/components/app/header/header_menu.tsx index 707cb241501fd..0ed01b7d3673e 100644 --- a/x-pack/plugins/observability/public/components/app/header/header_menu.tsx +++ b/x-pack/plugins/observability/public/components/app/header/header_menu.tsx @@ -26,7 +26,7 @@ export function ObservabilityHeaderMenu(): React.ReactElement | null { {addDataLinkText} diff --git a/x-pack/plugins/observability/public/utils/no_data_config.ts b/x-pack/plugins/observability/public/utils/no_data_config.ts index 1e16fb145bdce..2c87b1434a0b4 100644 --- a/x-pack/plugins/observability/public/utils/no_data_config.ts +++ b/x-pack/plugins/observability/public/utils/no_data_config.ts @@ -24,12 +24,15 @@ export function getNoDataConfig({ defaultMessage: 'Observability', }), actions: { - beats: { + elasticAgent: { + title: i18n.translate('xpack.observability.noDataConfig.beatsCard.title', { + defaultMessage: 'Add integrations', + }), description: i18n.translate('xpack.observability.noDataConfig.beatsCard.description', { defaultMessage: 'Use Beats and APM agents to send observability data to Elasticsearch. We make it easy with support for many popular systems, apps, and languages.', }), - href: basePath.prepend(`/app/home#/tutorial_directory/logging`), + href: basePath.prepend(`/app/integrations`), }, }, docsLink, diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 5c41e92661e58..5a7e19e2cdd05 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -16,7 +16,7 @@ export const APP_NAME = 'Security'; export const APP_ICON = 'securityAnalyticsApp'; export const APP_ICON_SOLUTION = 'logoSecurity'; export const APP_PATH = `/app/security`; -export const ADD_DATA_PATH = `/app/home#/tutorial_directory/security`; +export const ADD_DATA_PATH = `/app/integrations/browse/security`; export const DEFAULT_BYTES_FORMAT = 'format:bytes:defaultPattern'; export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx index 5fa2725f9ee6f..61e9e66f1bb87 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx @@ -45,9 +45,10 @@ describe('OverviewEmpty', () => { expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ actions: { elasticAgent: { + category: 'security', description: - 'Use Elastic Agent to collect security events and protect your endpoints from threats. Manage your agents in Fleet and add integrations with a single click.', - href: '/app/integrations/browse/security', + 'Use Elastic Agent to collect security events and protect your endpoints from threats.', + title: 'Add a Security integration', }, }, docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', @@ -68,8 +69,11 @@ describe('OverviewEmpty', () => { it('render with correct actions ', () => { expect(wrapper.find('[data-test-subj="empty-page"]').prop('noDataConfig')).toEqual({ actions: { - beats: { - href: '/app/home#/tutorial_directory/security', + elasticAgent: { + category: 'security', + description: + 'Use Elastic Agent to collect security events and protect your endpoints from threats.', + title: 'Add a Security integration', }, }, docsLink: 'https://www.elastic.co/guide/en/security/mocked-test-branch/index.html', diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index bc76333943191..9b20c079002e6 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -5,13 +5,10 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../common/lib/kibana'; -import { ADD_DATA_PATH } from '../../../../common/constants'; -import { pagePathGetters } from '../../../../../fleet/public'; import { SOLUTION_NAME } from '../../../../public/common/translations'; -import { useUserPrivileges } from '../../../common/components/user_privileges'; import { KibanaPageTemplate, @@ -19,42 +16,27 @@ import { } from '../../../../../../../src/plugins/kibana_react/public'; const OverviewEmptyComponent: React.FC = () => { - const { http, docLinks } = useKibana().services; - const basePath = http.basePath.get(); - const canAccessFleet = useUserPrivileges().endpointPrivileges.canAccessFleet; - const integrationsPathComponents = pagePathGetters.integrations_all({ category: 'security' }); - - const agentAction: NoDataPageActionsProps = useMemo( - () => ({ - elasticAgent: { - href: `${basePath}${integrationsPathComponents[0]}${integrationsPathComponents[1]}`, - description: i18n.translate( - 'xpack.securitySolution.pages.emptyPage.beatsCard.description', - { - defaultMessage: - 'Use Elastic Agent to collect security events and protect your endpoints from threats. Manage your agents in Fleet and add integrations with a single click.', - } - ), - }, - }), - [basePath, integrationsPathComponents] - ); - - const beatsAction: NoDataPageActionsProps = useMemo( - () => ({ - beats: { - href: `${basePath}${ADD_DATA_PATH}`, - }, - }), - [basePath] - ); + const { docLinks } = useKibana().services; + + const agentAction: NoDataPageActionsProps = { + elasticAgent: { + category: 'security', + title: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.title', { + defaultMessage: 'Add a Security integration', + }), + description: i18n.translate('xpack.securitySolution.pages.emptyPage.beatsCard.description', { + defaultMessage: + 'Use Elastic Agent to collect security events and protect your endpoints from threats.', + }), + }, + }; return ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f909a03909e3f..95f6909b12f6c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3014,11 +3014,7 @@ "home.tutorial.savedObject.unableToAddErrorMessage": "{savedObjectsLength} 件中 {errorsLength} 件の kibana オブジェクトが追加できません。エラー:{errorMessage}", "home.tutorial.selectionLegend": "デプロイタイプ", "home.tutorial.selfManagedButtonLabel": "自己管理", - "home.tutorial.tabs.allTitle": "すべて", - "home.tutorial.tabs.loggingTitle": "ログ", - "home.tutorial.tabs.metricsTitle": "メトリック", "home.tutorial.tabs.sampleDataTitle": "サンプルデータ", - "home.tutorial.tabs.securitySolutionTitle": "セキュリティ", "home.tutorial.unexpectedStatusCheckStateErrorDescription": "予期せぬステータス確認ステータス {statusCheckState}", "home.tutorial.unhandledInstructionTypeErrorDescription": "予期せぬ指示タイプ {visibleInstructions}", "home.tutorialDirectory.featureCatalogueDescription": "一般的なアプリやサービスからデータを取り込みます。", @@ -4209,8 +4205,6 @@ "kibana-react.noDataPage.cantDecide.link": "詳細については、ドキュメントをご確認ください。", "kibana-react.noDataPage.elasticAgentCard.description": "Elasticエージェントを使用すると、シンプルで統一された方法でコンピューターからデータを収集するできます。", "kibana-react.noDataPage.elasticAgentCard.title": "Elasticエージェントの追加", - "kibana-react.noDataPage.elasticBeatsCard.description": "Beatsを使用して、さまざまなシステムのデータをElasticsearchに追加します。", - "kibana-react.noDataPage.elasticBeatsCard.title": "データの追加", "kibana-react.noDataPage.intro": "データを追加して開始するか、{solution}については{link}をご覧ください。", "kibana-react.noDataPage.intro.link": "詳細", "kibana-react.noDataPage.noDataPage.recommended": "推奨", @@ -11205,12 +11199,7 @@ "xpack.fleet.fleetServerUpgradeModal.modalTitle": "エージェントをFleetサーバーに登録", "xpack.fleet.fleetServerUpgradeModal.onPremDescriptionMessage": "Fleetサーバーが使用できます。スケーラビリティとセキュリティが改善されています。{existingAgentsMessage} Fleetを使用し続けるには、Fleetサーバーと新しいバージョンのElasticエージェントを各ホストにインストールする必要があります。詳細については、{link}をご覧ください。", "xpack.fleet.genericActionsMenuText": "開く", - "xpack.fleet.homeIntegration.tutorialDirectory.dismissNoticeButtonText": "メッセージを消去", "xpack.fleet.homeIntegration.tutorialDirectory.fleetAppButtonText": "統合を試す", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeText": "Elasticエージェント統合では、シンプルかつ統合された方法で、ログ、メトリック、他の種類のデータの監視をホストに追加することができます。複数のBeatsをインストールする必要はありません。このため、インフラストラクチャ全体でのポリシーのデプロイが簡単で高速になりました。詳細については、{blogPostLink}をお読みください。", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeText.blogPostLink": "発表ブログ投稿", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeTitle": "{newPrefix} Elasticエージェント統合", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeTitle.newPrefix": "一般公開へ:", "xpack.fleet.homeIntegration.tutorialModule.noticeText": "{notePrefix}このモジュールの新しいバージョンは{availableAsIntegrationLink}です。統合と新しいElasticエージェントの詳細については、{blogPostLink}をお読みください。", "xpack.fleet.homeIntegration.tutorialModule.noticeText.blogPostLink": "発表ブログ投稿", "xpack.fleet.homeIntegration.tutorialModule.noticeText.integrationLink": "Elasticエージェント統合として提供", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 4de407cf8e464..80dad5a2c0c8b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3043,11 +3043,7 @@ "home.tutorial.savedObject.unableToAddErrorMessage": "{savedObjectsLength} 个 kibana 对象中有 {errorsLength} 个无法添加,错误:{errorMessage}", "home.tutorial.selectionLegend": "部署类型", "home.tutorial.selfManagedButtonLabel": "自管型", - "home.tutorial.tabs.allTitle": "全部", - "home.tutorial.tabs.loggingTitle": "日志", - "home.tutorial.tabs.metricsTitle": "指标", "home.tutorial.tabs.sampleDataTitle": "样例数据", - "home.tutorial.tabs.securitySolutionTitle": "安全", "home.tutorial.unexpectedStatusCheckStateErrorDescription": "意外的状态检查状态 {statusCheckState}", "home.tutorial.unhandledInstructionTypeErrorDescription": "未处理的指令类型 {visibleInstructions}", "home.tutorialDirectory.featureCatalogueDescription": "从热门应用和服务中采集数据。", @@ -4249,8 +4245,6 @@ "kibana-react.noDataPage.cantDecide.link": "请参阅我们的文档以了解更多信息。", "kibana-react.noDataPage.elasticAgentCard.description": "使用 Elastic 代理以简单统一的方式从您的计算机中收集数据。", "kibana-react.noDataPage.elasticAgentCard.title": "添加 Elastic 代理", - "kibana-react.noDataPage.elasticBeatsCard.description": "使用 Beats 将各种系统的数据添加到 Elasticsearch。", - "kibana-react.noDataPage.elasticBeatsCard.title": "添加数据", "kibana-react.noDataPage.intro": "添加您的数据以开始,或{link}{solution}。", "kibana-react.noDataPage.intro.link": "了解详情", "kibana-react.noDataPage.noDataPage.recommended": "推荐", @@ -11321,12 +11315,7 @@ "xpack.fleet.fleetServerUpgradeModal.modalTitle": "将代理注册到 Fleet 服务器", "xpack.fleet.fleetServerUpgradeModal.onPremDescriptionMessage": "Fleet 服务器现在可用且提供改善的可扩展性和安全性。{existingAgentsMessage}要继续使用 Fleet,必须在各个主机上安装 Fleet 服务器和新版 Elastic 代理。详细了解我们的 {link}。", "xpack.fleet.genericActionsMenuText": "打开", - "xpack.fleet.homeIntegration.tutorialDirectory.dismissNoticeButtonText": "关闭消息", "xpack.fleet.homeIntegration.tutorialDirectory.fleetAppButtonText": "试用集成", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeText": "通过 Elastic 代理集成,可以简单统一的方式将日志、指标和其他类型数据的监测添加到主机。不再需要安装多个 Beats,这样将策略部署到整个基础架构更容易也更快速。有关更多信息,请阅读我们的{blogPostLink}。", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeText.blogPostLink": "公告博客", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeTitle": "{newPrefix}Elastic 代理集成", - "xpack.fleet.homeIntegration.tutorialDirectory.noticeTitle.newPrefix": "已正式发布:", "xpack.fleet.homeIntegration.tutorialModule.noticeText": "{notePrefix}此模块的较新版本{availableAsIntegrationLink}。要详细了解集成和新 Elastic 代理,请阅读我们的{blogPostLink}。", "xpack.fleet.homeIntegration.tutorialModule.noticeText.blogPostLink": "公告博客", "xpack.fleet.homeIntegration.tutorialModule.noticeText.integrationLink": "将作为 Elastic 代理集成来提供", diff --git a/x-pack/test/accessibility/apps/home.ts b/x-pack/test/accessibility/apps/home.ts index a7158d9579b60..61297859c29f8 100644 --- a/x-pack/test/accessibility/apps/home.ts +++ b/x-pack/test/accessibility/apps/home.ts @@ -64,33 +64,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await a11y.testAppSnapshot(); }); - it('Add data page meets a11y requirements ', async () => { - await home.clickGoHome(); - await testSubjects.click('homeAddData'); - await a11y.testAppSnapshot(); - }); - - it('Sample data page meets a11y requirements ', async () => { - await testSubjects.click('homeTab-sampleData'); - await a11y.testAppSnapshot(); - }); - - it('click on Add logs panel to open all log examples page meets a11y requirements ', async () => { - await testSubjects.click('sampleDataSetCardlogs'); - await a11y.testAppSnapshot(); - }); - - it('click on ActiveMQ logs panel to open tutorial meets a11y requirements', async () => { - await testSubjects.click('homeTab-all'); - await testSubjects.click('homeSynopsisLinkactivemqlogs'); - await a11y.testAppSnapshot(); - }); - - it('click on cloud tutorial meets a11y requirements', async () => { - await testSubjects.click('onCloudTutorial'); - await a11y.testAppSnapshot(); - }); - it('passes with searchbox open', async () => { await testSubjects.click('nav-search-popover'); await a11y.testAppSnapshot(); From 828437621762ec327a5bf8f2b53927204d21776b Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 07:43:48 -0400 Subject: [PATCH 50/54] [RAC] [Metrics UI] Include group name in the reason message (#115171) (#115523) * [RAC] [Metrics UI] Include group name in the reason message * remove console log * fix i18n errors * fix more i18n errors * fix i18n & check errors and move group to the end of the reason text * add empty lines at the end of translation files * fix more i18n tests * try to remove manually added translations * Revert "try to remove manually added translations" This reverts commit 6949af2f70aff46b088bab5c942497ad46081d90. * apply i18n_check fix and reorder values in the formatted reason * log threshold reformat reason message and move group info at the end Co-authored-by: mgiota --- .../server/lib/alerting/common/messages.ts | 18 ++++++---- .../inventory_metric_threshold_executor.ts | 35 +++++++++++-------- .../log_threshold/reason_formatters.ts | 4 +-- .../metric_threshold_executor.ts | 9 ++--- .../translations/translations/ja-JP.json | 3 -- .../translations/translations/zh-CN.json | 3 -- 6 files changed, 40 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts index 084043f357bb1..23c89abf4a7aa 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/messages.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts @@ -109,15 +109,17 @@ const thresholdToI18n = ([a, b]: Array) => { }; export const buildFiredAlertReason: (alertResult: { + group: string; metric: string; comparator: Comparator; threshold: Array; currentValue: number | string; -}) => string = ({ metric, comparator, threshold, currentValue }) => +}) => string = ({ group, metric, comparator, threshold, currentValue }) => i18n.translate('xpack.infra.metrics.alerting.threshold.firedAlertReason', { defaultMessage: - '{metric} is {comparator} a threshold of {threshold} (current value is {currentValue})', + '{metric} is {comparator} a threshold of {threshold} (current value is {currentValue}) for {group}', values: { + group, metric, comparator: comparatorToI18n(comparator, threshold.map(toNumber), toNumber(currentValue)), threshold: thresholdToI18n(threshold), @@ -126,14 +128,15 @@ export const buildFiredAlertReason: (alertResult: { }); export const buildRecoveredAlertReason: (alertResult: { + group: string; metric: string; comparator: Comparator; threshold: Array; currentValue: number | string; -}) => string = ({ metric, comparator, threshold, currentValue }) => +}) => string = ({ group, metric, comparator, threshold, currentValue }) => i18n.translate('xpack.infra.metrics.alerting.threshold.recoveredAlertReason', { defaultMessage: - '{metric} is now {comparator} a threshold of {threshold} (current value is {currentValue})', + '{metric} is now {comparator} a threshold of {threshold} (current value is {currentValue}) for {group}', values: { metric, comparator: recoveredComparatorToI18n( @@ -143,19 +146,22 @@ export const buildRecoveredAlertReason: (alertResult: { ), threshold: thresholdToI18n(threshold), currentValue, + group, }, }); export const buildNoDataAlertReason: (alertResult: { + group: string; metric: string; timeSize: number; timeUnit: string; -}) => string = ({ metric, timeSize, timeUnit }) => +}) => string = ({ group, metric, timeSize, timeUnit }) => i18n.translate('xpack.infra.metrics.alerting.threshold.noDataAlertReason', { - defaultMessage: '{metric} has reported no data over the past {interval}', + defaultMessage: '{metric} has reported no data over the past {interval} for {group}', values: { metric, interval: `${timeSize}${timeUnit}`, + group, }, }); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 72d9ea9e39def..d8b66b35c703b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -111,18 +111,18 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ) ); const inventoryItems = Object.keys(first(results)!); - for (const item of inventoryItems) { + for (const group of inventoryItems) { // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => { // Grab the result of the most recent bucket - return last(result[item].shouldFire); + return last(result[group].shouldFire); }); - const shouldAlertWarn = results.every((result) => last(result[item].shouldWarn)); + const shouldAlertWarn = results.every((result) => last(result[group].shouldWarn)); // AND logic; because we need to evaluate all criteria, if one of them reports no data then the // whole alert is in a No Data/Error state - const isNoData = results.some((result) => last(result[item].isNoData)); - const isError = results.some((result) => result[item].isError); + const isNoData = results.some((result) => last(result[group].isNoData)); + const isError = results.some((result) => result[group].isError); const nextState = isError ? AlertStates.ERROR @@ -138,7 +138,8 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = reason = results .map((result) => buildReasonWithVerboseMetricName( - result[item], + group, + result[group], buildFiredAlertReason, nextState === AlertStates.WARNING ) @@ -151,19 +152,23 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = */ // } else if (nextState === AlertStates.OK && prevState?.alertState === AlertStates.ALERT) { // reason = results - // .map((result) => buildReasonWithVerboseMetricName(result[item], buildRecoveredAlertReason)) + // .map((result) => buildReasonWithVerboseMetricName(group, result[group], buildRecoveredAlertReason)) // .join('\n'); } if (alertOnNoData) { if (nextState === AlertStates.NO_DATA) { reason = results - .filter((result) => result[item].isNoData) - .map((result) => buildReasonWithVerboseMetricName(result[item], buildNoDataAlertReason)) + .filter((result) => result[group].isNoData) + .map((result) => + buildReasonWithVerboseMetricName(group, result[group], buildNoDataAlertReason) + ) .join('\n'); } else if (nextState === AlertStates.ERROR) { reason = results - .filter((result) => result[item].isError) - .map((result) => buildReasonWithVerboseMetricName(result[item], buildErrorAlertReason)) + .filter((result) => result[group].isError) + .map((result) => + buildReasonWithVerboseMetricName(group, result[group], buildErrorAlertReason) + ) .join('\n'); } } @@ -175,7 +180,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = ? WARNING_ACTIONS.id : FIRED_ACTIONS.id; - const alertInstance = alertInstanceFactory(`${item}`, reason); + const alertInstance = alertInstanceFactory(`${group}`, reason); alertInstance.scheduleActions( /** * TODO: We're lying to the compiler here as explicitly calling `scheduleActions` on @@ -183,12 +188,12 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = */ actionGroupId as unknown as InventoryMetricThresholdAllowedActionGroups, { - group: item, + group, alertState: stateToAlertMessage[nextState], reason, timestamp: moment().toISOString(), value: mapToConditionsLookup(results, (result) => - formatMetric(result[item].metric, result[item].currentValue) + formatMetric(result[group].metric, result[group].currentValue) ), threshold: mapToConditionsLookup(criteria, (c) => c.threshold), metric: mapToConditionsLookup(criteria, (c) => c.metric), @@ -199,6 +204,7 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = }); const buildReasonWithVerboseMetricName = ( + group: string, resultItem: any, buildReason: (r: any) => string, useWarningThreshold?: boolean @@ -206,6 +212,7 @@ const buildReasonWithVerboseMetricName = ( if (!resultItem) return ''; const resultWithVerboseMetricName = { ...resultItem, + group, metric: toMetricOpt(resultItem.metric)?.text || (resultItem.metric === 'custom' diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/reason_formatters.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/reason_formatters.ts index cd579b9965b66..f70e0a0140ce8 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/reason_formatters.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/reason_formatters.ts @@ -34,7 +34,7 @@ export const getReasonMessageForGroupedCountAlert = ( ) => i18n.translate('xpack.infra.logs.alerting.threshold.groupedCountAlertReasonDescription', { defaultMessage: - '{groupName}: {actualCount, plural, one {{actualCount} log entry} other {{actualCount} log entries} } ({translatedComparator} {expectedCount}) match the conditions.', + '{actualCount, plural, one {{actualCount} log entry} other {{actualCount} log entries} } ({translatedComparator} {expectedCount}) match the conditions for {groupName}.', values: { actualCount, expectedCount, @@ -66,7 +66,7 @@ export const getReasonMessageForGroupedRatioAlert = ( ) => i18n.translate('xpack.infra.logs.alerting.threshold.groupedRatioAlertReasonDescription', { defaultMessage: - '{groupName}: The log entries ratio is {actualRatio} ({translatedComparator} {expectedRatio}).', + 'The log entries ratio is {actualRatio} ({translatedComparator} {expectedRatio}) for {groupName}.', values: { actualRatio, expectedRatio, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index af5f945eeb4bb..e4887e922bb66 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -143,9 +143,10 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) { reason = alertResults .map((result) => - buildFiredAlertReason( - formatAlertResult(result[group], nextState === AlertStates.WARNING) - ) + buildFiredAlertReason({ + ...formatAlertResult(result[group], nextState === AlertStates.WARNING), + group, + }) ) .join('\n'); /* @@ -181,7 +182,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => if (nextState === AlertStates.NO_DATA) { reason = alertResults .filter((result) => result[group].isNoData) - .map((result) => buildNoDataAlertReason(result[group])) + .map((result) => buildNoDataAlertReason({ ...result[group], group })) .join('\n'); } else if (nextState === AlertStates.ERROR) { reason = alertResults diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 95f6909b12f6c..f86167046c4c2 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13580,15 +13580,12 @@ "xpack.infra.metrics.alerting.threshold.errorAlertReason": "{metric}のデータのクエリを試行しているときに、Elasticsearchが失敗しました", "xpack.infra.metrics.alerting.threshold.errorState": "エラー", "xpack.infra.metrics.alerting.threshold.fired": "アラート", - "xpack.infra.metrics.alerting.threshold.firedAlertReason": "{metric}は{comparator} {threshold}のしきい値です(現在の値は{currentValue})", "xpack.infra.metrics.alerting.threshold.gtComparator": "より大きい", "xpack.infra.metrics.alerting.threshold.ltComparator": "より小さい", - "xpack.infra.metrics.alerting.threshold.noDataAlertReason": "{metric}は過去{interval}にデータを報告していません", "xpack.infra.metrics.alerting.threshold.noDataFormattedValue": "[データなし]", "xpack.infra.metrics.alerting.threshold.noDataState": "データなし", "xpack.infra.metrics.alerting.threshold.okState": "OK [回復済み]", "xpack.infra.metrics.alerting.threshold.outsideRangeComparator": "の間にない", - "xpack.infra.metrics.alerting.threshold.recoveredAlertReason": "{metric}は{comparator} {threshold}のしきい値です(現在の値は{currentValue})", "xpack.infra.metrics.alerting.threshold.thresholdRange": "{a}と{b}", "xpack.infra.metrics.alerting.threshold.warning": "警告", "xpack.infra.metrics.alerting.threshold.warningState": "警告", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 80dad5a2c0c8b..6cfdc69c9e897 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13769,15 +13769,12 @@ "xpack.infra.metrics.alerting.threshold.errorAlertReason": "Elasticsearch 尝试查询 {metric} 的数据时出现故障", "xpack.infra.metrics.alerting.threshold.errorState": "错误", "xpack.infra.metrics.alerting.threshold.fired": "告警", - "xpack.infra.metrics.alerting.threshold.firedAlertReason": "{metric} {comparator}阈值 {threshold}(当前值为 {currentValue})", "xpack.infra.metrics.alerting.threshold.gtComparator": "大于", "xpack.infra.metrics.alerting.threshold.ltComparator": "小于", - "xpack.infra.metrics.alerting.threshold.noDataAlertReason": "{metric} 在过去 {interval}中未报告数据", "xpack.infra.metrics.alerting.threshold.noDataFormattedValue": "[无数据]", "xpack.infra.metrics.alerting.threshold.noDataState": "无数据", "xpack.infra.metrics.alerting.threshold.okState": "正常 [已恢复]", "xpack.infra.metrics.alerting.threshold.outsideRangeComparator": "不介于", - "xpack.infra.metrics.alerting.threshold.recoveredAlertReason": "{metric} 现在{comparator}阈值 {threshold}(当前值为 {currentValue})", "xpack.infra.metrics.alerting.threshold.thresholdRange": "{a} 和 {b}", "xpack.infra.metrics.alerting.threshold.warning": "警告", "xpack.infra.metrics.alerting.threshold.warningState": "警告", From fe17761ae8f644df5f10af1275fe0640eff7e4a2 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 19 Oct 2021 15:38:12 +0300 Subject: [PATCH 51/54] [VisEditors] Sets level for all registered deprecations (#115505) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/kibana_legacy/server/index.ts | 5 ++++- src/plugins/vis_types/metric/server/index.ts | 2 +- src/plugins/vis_types/table/server/index.ts | 4 ++-- src/plugins/vis_types/tagcloud/server/index.ts | 2 +- src/plugins/vis_types/timelion/server/index.ts | 15 ++++++++++----- src/plugins/vis_types/timeseries/server/index.ts | 8 +++++--- src/plugins/vis_types/vega/server/index.ts | 6 ++++-- 7 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/plugins/kibana_legacy/server/index.ts b/src/plugins/kibana_legacy/server/index.ts index 15ae8547c73e1..3f731efbfe857 100644 --- a/src/plugins/kibana_legacy/server/index.ts +++ b/src/plugins/kibana_legacy/server/index.ts @@ -18,7 +18,10 @@ export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ // TODO: Remove deprecation once defaultAppId is deleted - renameFromRoot('kibana.defaultAppId', 'kibana_legacy.defaultAppId', { silent: true }), + renameFromRoot('kibana.defaultAppId', 'kibana_legacy.defaultAppId', { + silent: true, + level: 'critical', + }), (completeConfig, rootPath, addDeprecation) => { if ( get(completeConfig, 'kibana.defaultAppId') === undefined && diff --git a/src/plugins/vis_types/metric/server/index.ts b/src/plugins/vis_types/metric/server/index.ts index 740fe3426dd84..0b6768074d6cb 100644 --- a/src/plugins/vis_types/metric/server/index.ts +++ b/src/plugins/vis_types/metric/server/index.ts @@ -13,7 +13,7 @@ import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('metric_vis.enabled', 'vis_type_metric.enabled'), + renameFromRoot('metric_vis.enabled', 'vis_type_metric.enabled', { level: 'critical' }), ], }; diff --git a/src/plugins/vis_types/table/server/index.ts b/src/plugins/vis_types/table/server/index.ts index eed1134f3ff48..103586305139c 100644 --- a/src/plugins/vis_types/table/server/index.ts +++ b/src/plugins/vis_types/table/server/index.ts @@ -15,9 +15,9 @@ import { registerVisTypeTableUsageCollector } from './usage_collector'; export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot, unused }) => [ - renameFromRoot('table_vis.enabled', 'vis_type_table.enabled'), + renameFromRoot('table_vis.enabled', 'vis_type_table.enabled', { level: 'critical' }), // Unused property which should be removed after releasing Kibana v8.0: - unused('legacyVisEnabled'), + unused('legacyVisEnabled', { level: 'critical' }), ], }; diff --git a/src/plugins/vis_types/tagcloud/server/index.ts b/src/plugins/vis_types/tagcloud/server/index.ts index 6899a333a8812..35c0a127c873f 100644 --- a/src/plugins/vis_types/tagcloud/server/index.ts +++ b/src/plugins/vis_types/tagcloud/server/index.ts @@ -13,7 +13,7 @@ import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('tagcloud.enabled', 'vis_type_tagcloud.enabled'), + renameFromRoot('tagcloud.enabled', 'vis_type_tagcloud.enabled', { level: 'critical' }), ], }; diff --git a/src/plugins/vis_types/timelion/server/index.ts b/src/plugins/vis_types/timelion/server/index.ts index ef7baf981de1a..b5985932188fb 100644 --- a/src/plugins/vis_types/timelion/server/index.ts +++ b/src/plugins/vis_types/timelion/server/index.ts @@ -13,12 +13,17 @@ import { TimelionPlugin } from './plugin'; export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot, unused }) => [ - renameFromRoot('timelion_vis.enabled', 'vis_type_timelion.enabled'), - renameFromRoot('timelion.enabled', 'vis_type_timelion.enabled'), - renameFromRoot('timelion.graphiteUrls', 'vis_type_timelion.graphiteUrls'), + renameFromRoot('timelion_vis.enabled', 'vis_type_timelion.enabled', { level: 'critical' }), + renameFromRoot('timelion.enabled', 'vis_type_timelion.enabled', { level: 'critical' }), + renameFromRoot('timelion.graphiteUrls', 'vis_type_timelion.graphiteUrls', { + level: 'critical', + }), // Unused properties which should be removed after releasing Kibana v8.0: - renameFromRoot('timelion.ui.enabled', 'vis_type_timelion.ui.enabled', { silent: true }), - unused('ui.enabled'), + renameFromRoot('timelion.ui.enabled', 'vis_type_timelion.ui.enabled', { + silent: true, + level: 'critical', + }), + unused('ui.enabled', { level: 'critical' }), ], }; diff --git a/src/plugins/vis_types/timeseries/server/index.ts b/src/plugins/vis_types/timeseries/server/index.ts index 0890b37e77926..ff2e54b31084b 100644 --- a/src/plugins/vis_types/timeseries/server/index.ts +++ b/src/plugins/vis_types/timeseries/server/index.ts @@ -15,17 +15,19 @@ export { VisTypeTimeseriesSetup } from './plugin'; export const config: PluginConfigDescriptor = { deprecations: ({ unused, renameFromRoot }) => [ // In Kibana v7.8 plugin id was renamed from 'metrics' to 'vis_type_timeseries': - renameFromRoot('metrics.enabled', 'vis_type_timeseries.enabled'), + renameFromRoot('metrics.enabled', 'vis_type_timeseries.enabled', { level: 'critical' }), renameFromRoot('metrics.chartResolution', 'vis_type_timeseries.chartResolution', { silent: true, + level: 'critical', }), renameFromRoot('metrics.minimumBucketSize', 'vis_type_timeseries.minimumBucketSize', { silent: true, + level: 'critical', }), // Unused properties which should be removed after releasing Kibana v8.0: - unused('chartResolution'), - unused('minimumBucketSize'), + unused('chartResolution', { level: 'critical' }), + unused('minimumBucketSize', { level: 'critical' }), ], schema: configSchema, }; diff --git a/src/plugins/vis_types/vega/server/index.ts b/src/plugins/vis_types/vega/server/index.ts index 156dec027372a..220d049c739ea 100644 --- a/src/plugins/vis_types/vega/server/index.ts +++ b/src/plugins/vis_types/vega/server/index.ts @@ -17,8 +17,10 @@ export const config: PluginConfigDescriptor = { }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('vega.enableExternalUrls', 'vis_type_vega.enableExternalUrls'), - renameFromRoot('vega.enabled', 'vis_type_vega.enabled'), + renameFromRoot('vega.enableExternalUrls', 'vis_type_vega.enableExternalUrls', { + level: 'critical', + }), + renameFromRoot('vega.enabled', 'vis_type_vega.enabled', { level: 'critical' }), ], }; From c2aae3be130cab8af34e088a07012f9861a5bfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 19 Oct 2021 14:57:24 +0200 Subject: [PATCH 52/54] [7.x] [Security Solution][Endpoint] Adds additional endpoint privileges to the `useUserPrivileges()` hook (#115051) (#115511) * [Security Solution][Endpoint] Adds additional endpoint privileges to the `useUserPrivileges()` hook (#115051) * Adds new `canIsolateHost` and `canCreateArtifactsByPolicy` privileges for endpoint * Refactor `useEndpointPrivileges` mocks to also provide a test function to return the full set of default privileges * refactor useEndpointPrivileges tests to be more resilient to future changes --- .../__mocks__/use_endpoint_privileges.ts | 18 ---- .../__mocks__/use_endpoint_privileges.ts | 12 +++ .../user_privileges/endpoint/index.ts | 9 ++ .../user_privileges/endpoint/mocks.ts | 29 ++++++ .../use_endpoint_privileges.test.ts | 89 ++++++++++--------- .../{ => endpoint}/use_endpoint_privileges.ts | 25 ++++-- .../user_privileges/endpoint/utils.ts | 19 ++++ .../components/user_privileges/index.tsx | 10 +-- .../components/user_info/index.test.tsx | 2 +- .../alerts/use_alerts_privileges.test.tsx | 6 +- .../alerts/use_signal_index.test.tsx | 2 +- .../search_exceptions.test.tsx | 17 ++-- .../search_exceptions/search_exceptions.tsx | 2 +- .../view/event_filters_list_page.test.tsx | 2 +- .../host_isolation_exceptions_list.test.tsx | 3 +- .../policy_trusted_apps_empty_unassigned.tsx | 2 +- .../policy_trusted_apps_flyout.test.tsx | 2 +- .../policy_trusted_apps_layout.test.tsx | 22 ++--- .../layout/policy_trusted_apps_layout.tsx | 2 +- .../list/policy_trusted_apps_list.test.tsx | 10 +-- .../list/policy_trusted_apps_list.tsx | 2 +- .../view/trusted_apps_page.test.tsx | 2 +- .../public/overview/pages/overview.test.tsx | 2 +- 23 files changed, 170 insertions(+), 119 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/__mocks__/use_endpoint_privileges.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/mocks.ts rename x-pack/plugins/security_solution/public/common/components/user_privileges/{ => endpoint}/use_endpoint_privileges.test.ts (63%) rename x-pack/plugins/security_solution/public/common/components/user_privileges/{ => endpoint}/use_endpoint_privileges.ts (71%) create mode 100644 x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/utils.ts diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts deleted file mode 100644 index 80ca534534187..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/__mocks__/use_endpoint_privileges.ts +++ /dev/null @@ -1,18 +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 { EndpointPrivileges } from '../use_endpoint_privileges'; - -export const useEndpointPrivileges = jest.fn(() => { - const endpointPrivilegesMock: EndpointPrivileges = { - loading: false, - canAccessFleet: true, - canAccessEndpointManagement: true, - isPlatinumPlus: true, - }; - return endpointPrivilegesMock; -}); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/__mocks__/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/__mocks__/use_endpoint_privileges.ts new file mode 100644 index 0000000000000..ae9aacaf3d55b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/__mocks__/use_endpoint_privileges.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getEndpointPrivilegesInitialStateMock } from '../mocks'; + +export { getEndpointPrivilegesInitialState } from '../utils'; + +export const useEndpointPrivileges = jest.fn(getEndpointPrivilegesInitialStateMock); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/index.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/index.ts new file mode 100644 index 0000000000000..adea89ce1a051 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/index.ts @@ -0,0 +1,9 @@ +/* + * 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 './use_endpoint_privileges'; +export { getEndpointPrivilegesInitialState } from './utils'; diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/mocks.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/mocks.ts new file mode 100644 index 0000000000000..2851c92816cea --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/mocks.ts @@ -0,0 +1,29 @@ +/* + * 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 { EndpointPrivileges } from './use_endpoint_privileges'; +import { getEndpointPrivilegesInitialState } from './utils'; + +export const getEndpointPrivilegesInitialStateMock = ( + overrides: Partial = {} +): EndpointPrivileges => { + // Get the initial state and set all permissions to `true` (enabled) for testing + const endpointPrivilegesMock: EndpointPrivileges = { + ...( + Object.entries(getEndpointPrivilegesInitialState()) as Array< + [keyof EndpointPrivileges, boolean] + > + ).reduce((mockPrivileges, [key, value]) => { + mockPrivileges[key] = !value; + + return mockPrivileges; + }, {} as EndpointPrivileges), + ...overrides, + }; + + return endpointPrivilegesMock; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts similarity index 63% rename from x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts rename to x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts index 82443e913499b..d4ba29a4ef950 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.test.ts @@ -6,16 +6,17 @@ */ import { act, renderHook, RenderHookResult, RenderResult } from '@testing-library/react-hooks'; -import { useHttp, useCurrentUser } from '../../lib/kibana'; +import { useHttp, useCurrentUser } from '../../../lib/kibana'; import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; -import { securityMock } from '../../../../../security/public/mocks'; -import { appRoutesService } from '../../../../../fleet/common'; -import { AuthenticatedUser } from '../../../../../security/common'; -import { licenseService } from '../../hooks/use_license'; -import { fleetGetCheckPermissionsHttpMock } from '../../../management/pages/mocks'; - -jest.mock('../../lib/kibana'); -jest.mock('../../hooks/use_license', () => { +import { securityMock } from '../../../../../../security/public/mocks'; +import { appRoutesService } from '../../../../../../fleet/common'; +import { AuthenticatedUser } from '../../../../../../security/common'; +import { licenseService } from '../../../hooks/use_license'; +import { fleetGetCheckPermissionsHttpMock } from '../../../../management/pages/mocks'; +import { getEndpointPrivilegesInitialStateMock } from './mocks'; + +jest.mock('../../../lib/kibana'); +jest.mock('../../../hooks/use_license', () => { const licenseServiceInstance = { isPlatinumPlus: jest.fn(), }; @@ -27,6 +28,8 @@ jest.mock('../../hooks/use_license', () => { }; }); +const licenseServiceMock = licenseService as jest.Mocked; + describe('When using useEndpointPrivileges hook', () => { let authenticatedUser: AuthenticatedUser; let fleetApiMock: ReturnType; @@ -45,7 +48,7 @@ describe('When using useEndpointPrivileges hook', () => { fleetApiMock = fleetGetCheckPermissionsHttpMock( useHttp() as Parameters[0] ); - (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); + licenseServiceMock.isPlatinumPlus.mockReturnValue(true); render = () => { const hookRenderResponse = renderHook(() => useEndpointPrivileges()); @@ -69,34 +72,31 @@ describe('When using useEndpointPrivileges hook', () => { (useCurrentUser as jest.Mock).mockReturnValue(null); const { rerender } = render(); - expect(result.current).toEqual({ - canAccessEndpointManagement: false, - canAccessFleet: false, - loading: true, - isPlatinumPlus: true, - }); + expect(result.current).toEqual( + getEndpointPrivilegesInitialStateMock({ + canAccessEndpointManagement: false, + canAccessFleet: false, + loading: true, + }) + ); // Make user service available (useCurrentUser as jest.Mock).mockReturnValue(authenticatedUser); rerender(); - expect(result.current).toEqual({ - canAccessEndpointManagement: false, - canAccessFleet: false, - loading: true, - isPlatinumPlus: true, - }); + expect(result.current).toEqual( + getEndpointPrivilegesInitialStateMock({ + canAccessEndpointManagement: false, + canAccessFleet: false, + loading: true, + }) + ); // Release the API response await act(async () => { fleetApiMock.waitForApi(); releaseApiResponse!(); }); - expect(result.current).toEqual({ - canAccessEndpointManagement: true, - canAccessFleet: true, - loading: false, - isPlatinumPlus: true, - }); + expect(result.current).toEqual(getEndpointPrivilegesInitialStateMock()); }); it('should call Fleet permissions api to determine user privilege to fleet', async () => { @@ -113,12 +113,11 @@ describe('When using useEndpointPrivileges hook', () => { render(); await waitForNextUpdate(); await fleetApiMock.waitForApi(); - expect(result.current).toEqual({ - canAccessEndpointManagement: false, - canAccessFleet: true, // this is only true here because I did not adjust the API mock - loading: false, - isPlatinumPlus: true, - }); + expect(result.current).toEqual( + getEndpointPrivilegesInitialStateMock({ + canAccessEndpointManagement: false, + }) + ); }); it('should set privileges to false if fleet api check returns failure', async () => { @@ -130,11 +129,21 @@ describe('When using useEndpointPrivileges hook', () => { render(); await waitForNextUpdate(); await fleetApiMock.waitForApi(); - expect(result.current).toEqual({ - canAccessEndpointManagement: false, - canAccessFleet: false, - loading: false, - isPlatinumPlus: true, - }); + expect(result.current).toEqual( + getEndpointPrivilegesInitialStateMock({ + canAccessEndpointManagement: false, + canAccessFleet: false, + }) + ); }); + + it.each([['canIsolateHost'], ['canCreateArtifactsByPolicy']])( + 'should set %s to false if license is not PlatinumPlus', + async (privilege) => { + licenseServiceMock.isPlatinumPlus.mockReturnValue(false); + render(); + await waitForNextUpdate(); + expect(result.current).toEqual(expect.objectContaining({ [privilege]: false })); + } + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts similarity index 71% rename from x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts rename to x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts index 315935104d107..36f02d22487fc 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/use_endpoint_privileges.ts +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/use_endpoint_privileges.ts @@ -6,9 +6,9 @@ */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { useCurrentUser, useHttp } from '../../lib/kibana'; -import { appRoutesService, CheckPermissionsResponse } from '../../../../../fleet/common'; -import { useLicense } from '../../hooks/use_license'; +import { useCurrentUser, useHttp } from '../../../lib/kibana'; +import { appRoutesService, CheckPermissionsResponse } from '../../../../../../fleet/common'; +import { useLicense } from '../../../hooks/use_license'; export interface EndpointPrivileges { loading: boolean; @@ -16,6 +16,11 @@ export interface EndpointPrivileges { canAccessFleet: boolean; /** If user has permissions to access Endpoint management (includes check to ensure they also have access to fleet) */ canAccessEndpointManagement: boolean; + /** if user has permissions to create Artifacts by Policy */ + canCreateArtifactsByPolicy: boolean; + /** If user has permissions to use the Host isolation feature */ + canIsolateHost: boolean; + /** @deprecated do not use. instead, use one of the other privileges defined */ isPlatinumPlus: boolean; } @@ -29,7 +34,7 @@ export const useEndpointPrivileges = (): EndpointPrivileges => { const http = useHttp(); const user = useCurrentUser(); const isMounted = useRef(true); - const license = useLicense(); + const isPlatinumPlusLicense = useLicense().isPlatinumPlus(); const [canAccessFleet, setCanAccessFleet] = useState(false); const [fleetCheckDone, setFleetCheckDone] = useState(false); @@ -61,13 +66,19 @@ export const useEndpointPrivileges = (): EndpointPrivileges => { }, [user?.roles]); const privileges = useMemo(() => { - return { + const privilegeList: EndpointPrivileges = { loading: !fleetCheckDone || !user, canAccessFleet, canAccessEndpointManagement: canAccessFleet && isSuperUser, - isPlatinumPlus: license.isPlatinumPlus(), + canCreateArtifactsByPolicy: isPlatinumPlusLicense, + canIsolateHost: isPlatinumPlusLicense, + // FIXME: Remove usages of the property below + /** @deprecated */ + isPlatinumPlus: isPlatinumPlusLicense, }; - }, [canAccessFleet, fleetCheckDone, isSuperUser, user, license]); + + return privilegeList; + }, [canAccessFleet, fleetCheckDone, isSuperUser, user, isPlatinumPlusLicense]); // Capture if component is unmounted useEffect( diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/utils.ts b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/utils.ts new file mode 100644 index 0000000000000..df91314479f18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/endpoint/utils.ts @@ -0,0 +1,19 @@ +/* + * 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 { EndpointPrivileges } from './use_endpoint_privileges'; + +export const getEndpointPrivilegesInitialState = (): EndpointPrivileges => { + return { + loading: true, + canAccessFleet: false, + canAccessEndpointManagement: false, + canIsolateHost: false, + canCreateArtifactsByPolicy: false, + isPlatinumPlus: false, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx index 437d27278102b..bc0640296b33d 100644 --- a/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/user_privileges/index.tsx @@ -11,9 +11,10 @@ import { DeepReadonly } from 'utility-types'; import { Capabilities } from '../../../../../../../src/core/public'; import { useFetchDetectionEnginePrivileges } from '../../../detections/components/user_privileges/use_fetch_detection_engine_privileges'; import { useFetchListPrivileges } from '../../../detections/components/user_privileges/use_fetch_list_privileges'; -import { EndpointPrivileges, useEndpointPrivileges } from './use_endpoint_privileges'; +import { EndpointPrivileges, useEndpointPrivileges } from './endpoint'; import { SERVER_APP_ID } from '../../../../common/constants'; +import { getEndpointPrivilegesInitialState } from './endpoint/utils'; export interface UserPrivilegesState { listPrivileges: ReturnType; detectionEnginePrivileges: ReturnType; @@ -24,12 +25,7 @@ export interface UserPrivilegesState { export const initialUserPrivilegesState = (): UserPrivilegesState => ({ listPrivileges: { loading: false, error: undefined, result: undefined }, detectionEnginePrivileges: { loading: false, error: undefined, result: undefined }, - endpointPrivileges: { - loading: true, - canAccessEndpointManagement: false, - canAccessFleet: false, - isPlatinumPlus: false, - }, + endpointPrivileges: getEndpointPrivilegesInitialState(), kibanaSecuritySolutionsPrivileges: { crud: false, read: false }, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx index 3d95dca81165e..1a8588017e4d6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/user_info/index.test.tsx @@ -17,7 +17,7 @@ import { UserPrivilegesProvider } from '../../../common/components/user_privileg jest.mock('../../../common/lib/kibana'); jest.mock('../../containers/detection_engine/alerts/api'); -jest.mock('../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); describe('useUserInfo', () => { beforeAll(() => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx index 40894c1d01929..1dc1423606097 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_alerts_privileges.test.tsx @@ -12,6 +12,7 @@ import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { Privilege } from './types'; import { UseAlertsPrivelegesReturn, useAlertsPrivileges } from './use_alerts_privileges'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../common/components/user_privileges/endpoint/mocks'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); @@ -86,12 +87,11 @@ const userPrivilegesInitial: ReturnType = { result: undefined, error: undefined, }, - endpointPrivileges: { + endpointPrivileges: getEndpointPrivilegesInitialStateMock({ loading: true, canAccessEndpointManagement: false, canAccessFleet: false, - isPlatinumPlus: true, - }, + }), kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx index ade83fed4fd6b..ad4ad5062c9d5 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/use_signal_index.test.tsx @@ -13,7 +13,7 @@ import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; jest.mock('./api'); jest.mock('../../../../common/hooks/use_app_toasts'); -jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); describe('useSignalIndex', () => { let appToastsMock: jest.Mocked>; diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx index 084978d35d03a..3b987a7211411 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.test.tsx @@ -11,11 +11,12 @@ import { AppContextTestRender, createAppRootMockRenderer } from '../../../common import { EndpointPrivileges, useEndpointPrivileges, -} from '../../../common/components/user_privileges/use_endpoint_privileges'; +} from '../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; import { SearchExceptions, SearchExceptionsProps } from '.'; -jest.mock('../../../common/components/user_privileges/use_endpoint_privileges'); +import { getEndpointPrivilegesInitialStateMock } from '../../../common/components/user_privileges/endpoint/mocks'; +jest.mock('../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); let onSearchMock: jest.Mock; const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; @@ -29,13 +30,11 @@ describe('Search exceptions', () => { const loadedUserEndpointPrivilegesState = ( endpointOverrides: Partial = {} - ): EndpointPrivileges => ({ - loading: false, - canAccessFleet: true, - canAccessEndpointManagement: true, - isPlatinumPlus: false, - ...endpointOverrides, - }); + ): EndpointPrivileges => + getEndpointPrivilegesInitialStateMock({ + isPlatinumPlus: false, + ...endpointOverrides, + }); beforeEach(() => { onSearchMock = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx index 1f3eab5db2947..569916ac20315 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx @@ -10,7 +10,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiButton } from '@elastic/e import { i18n } from '@kbn/i18n'; import { PolicySelectionItem, PoliciesSelector } from '../policies_selector'; import { ImmutableArray, PolicyData } from '../../../../common/endpoint/types'; -import { useEndpointPrivileges } from '../../../common/components/user_privileges/use_endpoint_privileges'; +import { useEndpointPrivileges } from '../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; export interface SearchExceptionsProps { defaultValue?: string; diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx index 0729f95bb44a9..02efce1ab59e8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx @@ -14,7 +14,7 @@ import { isFailedResourceState, isLoadedResourceState } from '../../../state'; // Needed to mock the data services used by the ExceptionItem component jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); describe('When on the Event Filters List Page', () => { let render: () => ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index 5113457e5bccc..625da11a3644e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -16,8 +16,7 @@ import { getHostIsolationExceptionItems } from '../service'; import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; import { useLicense } from '../../../../common/hooks/use_license'; -jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); - +jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); jest.mock('../service'); jest.mock('../../../../common/hooks/use_license'); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx index ee52e1210a481..c12bec03ada04 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/empty/policy_trusted_apps_empty_unassigned.tsx @@ -10,7 +10,7 @@ import { EuiEmptyPrompt, EuiButton, EuiPageTemplate, EuiLink } from '@elastic/eu import { FormattedMessage } from '@kbn/i18n/react'; import { usePolicyDetailsNavigateCallback } from '../../policy_hooks'; import { useGetLinkTo } from './use_policy_trusted_apps_empty_hooks'; -import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; interface CommonProps { policyId: string; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx index c1d00f7a3f99b..8e412d2020b72 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx @@ -21,7 +21,7 @@ import { createLoadedResourceState, isLoadedResourceState } from '../../../../.. import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; jest.mock('../../../../trusted_apps/service'); -jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); let mockedContext: AppContextTestRender; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx index 43e19c00bcc8e..dbb18a1b0f2ef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.test.tsx @@ -19,13 +19,11 @@ import { createLoadedResourceState, isLoadedResourceState } from '../../../../.. import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; import { EndpointDocGenerator } from '../../../../../../../common/endpoint/generate_data'; import { policyListApiPathHandlers } from '../../../store/test_mock_utils'; -import { - EndpointPrivileges, - useEndpointPrivileges, -} from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; jest.mock('../../../../trusted_apps/service'); -jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; let mockedContext: AppContextTestRender; @@ -37,16 +35,6 @@ let http: typeof coreStart.http; const generator = new EndpointDocGenerator(); describe('Policy trusted apps layout', () => { - const loadedUserEndpointPrivilegesState = ( - endpointOverrides: Partial = {} - ): EndpointPrivileges => ({ - loading: false, - canAccessFleet: true, - canAccessEndpointManagement: true, - isPlatinumPlus: true, - ...endpointOverrides, - }); - beforeEach(() => { mockedContext = createAppRootMockRenderer(); http = mockedContext.coreStart.http; @@ -137,7 +125,7 @@ describe('Policy trusted apps layout', () => { it('should hide assign button on empty state with unassigned policies when downgraded to a gold or below license', async () => { mockUseEndpointPrivileges.mockReturnValue( - loadedUserEndpointPrivilegesState({ + getEndpointPrivilegesInitialStateMock({ isPlatinumPlus: false, }) ); @@ -155,7 +143,7 @@ describe('Policy trusted apps layout', () => { it('should hide the `Assign trusted applications` button when there is data and the license is downgraded to gold or below', async () => { mockUseEndpointPrivileges.mockReturnValue( - loadedUserEndpointPrivilegesState({ + getEndpointPrivilegesInitialStateMock({ isPlatinumPlus: false, }) ); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx index a3f1ed215286a..49f76ad2e02c6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx @@ -30,7 +30,7 @@ import { import { usePolicyDetailsNavigateCallback, usePolicyDetailsSelector } from '../../policy_hooks'; import { PolicyTrustedAppsFlyout } from '../flyout'; import { PolicyTrustedAppsList } from '../list/policy_trusted_apps_list'; -import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; import { useAppUrl } from '../../../../../../common/lib/kibana'; import { APP_ID } from '../../../../../../../common/constants'; import { getTrustedAppsListPath } from '../../../../../common/routing'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx index b5bfc16db2899..9165aec3bef8d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.test.tsx @@ -24,9 +24,10 @@ import { APP_ID } from '../../../../../../../common/constants'; import { EndpointPrivileges, useEndpointPrivileges, -} from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +} from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; +import { getEndpointPrivilegesInitialStateMock } from '../../../../../../common/components/user_privileges/endpoint/mocks'; -jest.mock('../../../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); const mockUseEndpointPrivileges = useEndpointPrivileges as jest.Mock; describe('when rendering the PolicyTrustedAppsList', () => { @@ -43,10 +44,7 @@ describe('when rendering the PolicyTrustedAppsList', () => { const loadedUserEndpointPrivilegesState = ( endpointOverrides: Partial = {} ): EndpointPrivileges => ({ - loading: false, - canAccessFleet: true, - canAccessEndpointManagement: true, - isPlatinumPlus: true, + ...getEndpointPrivilegesInitialStateMock(), ...endpointOverrides, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx index 7b1f8753831c8..89ff6bd099be4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/list/policy_trusted_apps_list.tsx @@ -38,7 +38,7 @@ import { ContextMenuItemNavByRouterProps } from '../../../../../components/conte import { ArtifactEntryCollapsibleCardProps } from '../../../../../components/artifact_entry_card'; import { useTestIdGenerator } from '../../../../../components/hooks/use_test_id_generator'; import { RemoveTrustedAppFromPolicyModal } from './remove_trusted_app_from_policy_modal'; -import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/use_endpoint_privileges'; +import { useEndpointPrivileges } from '../../../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'; const DATA_TEST_SUBJ = 'policyTrustedAppsGrid'; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx index f39fd47c78771..b4366a8922927 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.test.tsx @@ -52,7 +52,7 @@ jest.mock('../../../../common/hooks/use_license', () => { }; }); -jest.mock('../../../../common/components/user_privileges/use_endpoint_privileges'); +jest.mock('../../../../common/components/user_privileges/endpoint/use_endpoint_privileges'); describe('When on the Trusted Apps Page', () => { const expectedAboutInfo = diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index cab02450f8886..ab5ae4f613e38 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -30,7 +30,7 @@ import { mockCtiLinksResponse, } from '../components/overview_cti_links/mock'; import { useCtiDashboardLinks } from '../containers/overview_cti_links'; -import { EndpointPrivileges } from '../../common/components/user_privileges/use_endpoint_privileges'; +import { EndpointPrivileges } from '../../common/components/user_privileges/endpoint/use_endpoint_privileges'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; import { useHostsRiskScore } from '../containers/overview_risky_host_links/use_hosts_risk_score'; From bd42367bdbfb028c805cc0a22b967e32367e2a86 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 19 Oct 2021 09:27:09 -0400 Subject: [PATCH 53/54] [ML] Delete annotation directly from the index it is stored in (#115328) (#115539) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Pete Harverson --- .../ml/common/constants/index_patterns.ts | 1 - .../ml/server/lib/check_annotations/index.ts | 11 ++----- .../annotation_service/annotation.test.ts | 3 +- .../models/annotation_service/annotation.ts | 33 ++++++++++++++++--- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/ml/common/constants/index_patterns.ts b/x-pack/plugins/ml/common/constants/index_patterns.ts index d7d6c343e282b..9a8e5c1b8ae78 100644 --- a/x-pack/plugins/ml/common/constants/index_patterns.ts +++ b/x-pack/plugins/ml/common/constants/index_patterns.ts @@ -7,7 +7,6 @@ export const ML_ANNOTATIONS_INDEX_ALIAS_READ = '.ml-annotations-read'; export const ML_ANNOTATIONS_INDEX_ALIAS_WRITE = '.ml-annotations-write'; -export const ML_ANNOTATIONS_INDEX_PATTERN = '.ml-annotations-6'; export const ML_RESULTS_INDEX_PATTERN = '.ml-anomalies-*'; export const ML_NOTIFICATION_INDEX_PATTERN = '.ml-notifications*'; diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.ts index a388a24d082a6..e64b4658588cb 100644 --- a/x-pack/plugins/ml/server/lib/check_annotations/index.ts +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts @@ -11,22 +11,15 @@ import { mlLog } from '../../lib/log'; import { ML_ANNOTATIONS_INDEX_ALIAS_READ, ML_ANNOTATIONS_INDEX_ALIAS_WRITE, - ML_ANNOTATIONS_INDEX_PATTERN, } from '../../../common/constants/index_patterns'; // Annotations Feature is available if: -// - ML_ANNOTATIONS_INDEX_PATTERN index is present // - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present // - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present +// Note there is no need to check for the existence of the indices themselves as aliases are stored +// in the metadata of the indices they point to, so it's impossible to have an alias that doesn't point to any index. export async function isAnnotationsFeatureAvailable({ asInternalUser }: IScopedClusterClient) { try { - const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; - - const { body: annotationsIndexExists } = await asInternalUser.indices.exists(indexParams); - if (!annotationsIndexExists) { - return false; - } - const { body: annotationsReadAliasExists } = await asInternalUser.indices.existsAlias({ index: ML_ANNOTATIONS_INDEX_ALIAS_READ, name: ML_ANNOTATIONS_INDEX_ALIAS_READ, diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 725e0ac494944..975070e92a7ec 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -9,7 +9,6 @@ import getAnnotationsRequestMock from './__mocks__/get_annotations_request.json' import getAnnotationsResponseMock from './__mocks__/get_annotations_response.json'; import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; -import { ML_ANNOTATIONS_INDEX_ALIAS_WRITE } from '../../../common/constants/index_patterns'; import { Annotation, isAnnotations } from '../../../common/types/annotations'; import { DeleteParams, GetResponse, IndexAnnotationArgs } from './annotation'; @@ -42,7 +41,7 @@ describe('annotation_service', () => { const annotationMockId = 'mockId'; const deleteParamsMock: DeleteParams = { - index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + index: '.ml-annotations-6', id: annotationMockId, refresh: 'wait_for', }; diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index c6ed72de18d05..5807d181cc566 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -71,6 +71,7 @@ export interface IndexParams { index: string; body: Annotation; refresh: boolean | 'wait_for' | undefined; + require_alias?: boolean; id?: string; } @@ -99,6 +100,7 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, body: annotation, refresh: 'wait_for', + require_alias: true, }; if (typeof annotation._id !== 'undefined') { @@ -407,14 +409,37 @@ export function annotationProvider({ asInternalUser }: IScopedClusterClient) { } async function deleteAnnotation(id: string) { - const params: DeleteParams = { - index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, + // Find the index the annotation is stored in. + const searchParams: estypes.SearchRequest = { + index: ML_ANNOTATIONS_INDEX_ALIAS_READ, + size: 1, + body: { + query: { + ids: { + values: [id], + }, + }, + }, + }; + + const { body } = await asInternalUser.search(searchParams); + const totalCount = + typeof body.hits.total === 'number' ? body.hits.total : body.hits.total.value; + + if (totalCount === 0) { + throw Boom.notFound(`Cannot find annotation with ID ${id}`); + } + + const index = body.hits.hits[0]._index; + + const deleteParams: DeleteParams = { + index, id, refresh: 'wait_for', }; - const { body } = await asInternalUser.delete(params); - return body; + const { body: deleteResponse } = await asInternalUser.delete(deleteParams); + return deleteResponse; } return { From 4317d066ffb0432c045d826ef6eb119e376d45a8 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 19 Oct 2021 15:52:00 +0200 Subject: [PATCH 54/54] [7.x] Explicitly set `level` for core deprecations (#115501) * Explicitly set `level` for core deprecations * Set for internal deprecations * fix unit tests --- .../config/deprecation/core_deprecations.ts | 93 ++++++++++++------- src/core/server/kibana_config.ts | 1 + src/plugins/home/server/index.ts | 4 +- src/plugins/newsfeed/server/index.ts | 2 +- .../server/config/deprecations.test.ts | 2 + .../telemetry/server/config/deprecations.ts | 1 + src/plugins/usage_collection/server/config.ts | 14 ++- .../licensing/server/licensing_config.ts | 3 +- 8 files changed, 78 insertions(+), 42 deletions(-) diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 1cf67f479f9b3..b115ea25fc363 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -13,6 +13,7 @@ const kibanaPathConf: ConfigDeprecation = (settings, fromPath, addDeprecation) = if (process.env?.KIBANA_PATH_CONF) { addDeprecation({ configPath: 'env.KIBANA_PATH_CONF', + level: 'critical', message: `Environment variable "KIBANA_PATH_CONF" is deprecated. It has been replaced with "KBN_PATH_CONF" pointing to a config folder`, correctiveActions: { manualSteps: [ @@ -27,6 +28,7 @@ const configPathDeprecation: ConfigDeprecation = (settings, fromPath, addDepreca if (process.env?.CONFIG_PATH) { addDeprecation({ configPath: 'env.CONFIG_PATH', + level: 'critical', message: `Environment variable "CONFIG_PATH" is deprecated. It has been replaced with "KBN_PATH_CONF" pointing to a config folder`, correctiveActions: { manualSteps: ['Use "KBN_PATH_CONF" instead of "CONFIG_PATH" to point to a config folder.'], @@ -39,6 +41,7 @@ const dataPathDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati if (process.env?.DATA_PATH) { addDeprecation({ configPath: 'env.DATA_PATH', + level: 'critical', message: `Environment variable "DATA_PATH" will be removed. It has been replaced with kibana.yml setting "path.data"`, correctiveActions: { manualSteps: [ @@ -53,13 +56,13 @@ const rewriteBasePathDeprecation: ConfigDeprecation = (settings, fromPath, addDe if (settings.server?.basePath && !settings.server?.rewriteBasePath) { addDeprecation({ configPath: 'server.basePath', + level: 'warning', title: 'Setting "server.rewriteBasePath" should be set when using "server.basePath"', message: 'You should set server.basePath along with server.rewriteBasePath. Starting in 7.0, Kibana ' + 'will expect that all requests start with server.basePath rather than expecting you to rewrite ' + 'the requests in your reverse proxy. Set server.rewriteBasePath to false to preserve the ' + 'current behavior and silence this warning.', - level: 'warning', correctiveActions: { manualSteps: [ `Set 'server.rewriteBasePath' in the config file, CLI flag, or environment variable (in Docker only).`, @@ -75,6 +78,7 @@ const rewriteCorsSettings: ConfigDeprecation = (settings, fromPath, addDeprecati if (typeof corsSettings === 'boolean') { addDeprecation({ configPath: 'server.cors', + level: 'warning', title: 'Setting "server.cors" is deprecated', message: '"server.cors" is deprecated and has been replaced by "server.cors.enabled"', correctiveActions: { @@ -113,6 +117,7 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati if (sourceList.find((source) => source.includes(NONCE_STRING))) { addDeprecation({ configPath: 'csp.rules', + level: 'critical', message: `csp.rules no longer supports the {nonce} syntax. Replacing with 'self' in ${policy}`, correctiveActions: { manualSteps: [`Replace {nonce} syntax with 'self' in ${policy}`], @@ -132,6 +137,7 @@ const cspRulesDeprecation: ConfigDeprecation = (settings, fromPath, addDeprecati ) { addDeprecation({ configPath: 'csp.rules', + level: 'critical', message: `csp.rules must contain the 'self' source. Automatically adding to ${policy}.`, correctiveActions: { manualSteps: [`Add 'self' source to ${policy}.`], @@ -156,6 +162,7 @@ const mapManifestServiceUrlDeprecation: ConfigDeprecation = ( if (settings.map?.manifestServiceUrl) { addDeprecation({ configPath: 'map.manifestServiceUrl', + level: 'critical', message: 'You should no longer use the map.manifestServiceUrl setting in kibana.yml to configure the location ' + 'of the Elastic Maps Service settings. These settings have moved to the "map.emsTileApiUrl" and ' + @@ -175,6 +182,7 @@ const serverHostZeroDeprecation: ConfigDeprecation = (settings, fromPath, addDep if (settings.server?.host === '0') { addDeprecation({ configPath: 'server.host', + level: 'critical', message: 'Support for setting server.host to "0" in kibana.yml is deprecated and will be removed in Kibana version 8.0.0. ' + 'Instead use "0.0.0.0" to bind to all interfaces.', @@ -206,6 +214,7 @@ const opsLoggingEventDeprecation: ConfigDeprecation = ( if (settings.logging?.events?.ops) { addDeprecation({ configPath: 'logging.events.ops', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingevents`, title: i18n.translate('core.deprecations.loggingEventsOps.deprecationTitle', { defaultMessage: `Setting "logging.events.ops" is deprecated`, @@ -237,6 +246,7 @@ const requestLoggingEventDeprecation: ConfigDeprecation = ( if (settings.logging?.events?.request) { addDeprecation({ configPath: 'logging.events.request', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingevents`, title: i18n.translate('core.deprecations.loggingEventsRequest.deprecationTitle', { defaultMessage: `Setting "logging.events.request" is deprecated`, @@ -268,6 +278,7 @@ const responseLoggingEventDeprecation: ConfigDeprecation = ( if (settings.logging?.events?.response) { addDeprecation({ configPath: 'logging.events.response', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingevents`, title: i18n.translate('core.deprecations.loggingEventsResponse.deprecationTitle', { defaultMessage: `Setting "logging.events.response" is deprecated`, @@ -299,6 +310,7 @@ const timezoneLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.timezone) { addDeprecation({ configPath: 'logging.timezone', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingtimezone`, title: i18n.translate('core.deprecations.loggingTimezone.deprecationTitle', { defaultMessage: `Setting "logging.timezone" is deprecated`, @@ -330,6 +342,7 @@ const destLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.dest) { addDeprecation({ configPath: 'logging.dest', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingdest`, title: i18n.translate('core.deprecations.loggingDest.deprecationTitle', { defaultMessage: `Setting "logging.dest" is deprecated`, @@ -361,6 +374,7 @@ const quietLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.quiet) { addDeprecation({ configPath: 'logging.quiet', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingquiet`, title: i18n.translate('core.deprecations.loggingQuiet.deprecationTitle', { defaultMessage: `Setting "logging.quiet" is deprecated`, @@ -391,6 +405,7 @@ const silentLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.silent) { addDeprecation({ configPath: 'logging.silent', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingsilent`, title: i18n.translate('core.deprecations.loggingSilent.deprecationTitle', { defaultMessage: `Setting "logging.silent" is deprecated`, @@ -421,6 +436,7 @@ const verboseLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.verbose) { addDeprecation({ configPath: 'logging.verbose', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingverbose`, title: i18n.translate('core.deprecations.loggingVerbose.deprecationTitle', { defaultMessage: `Setting "logging.verbose" is deprecated`, @@ -455,6 +471,7 @@ const jsonLoggingDeprecation: ConfigDeprecation = ( if (settings.logging?.json && settings.env !== 'development') { addDeprecation({ configPath: 'logging.json', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx`, title: i18n.translate('core.deprecations.loggingJson.deprecationTitle', { defaultMessage: `Setting "logging.json" is deprecated`, @@ -487,6 +504,7 @@ const logRotateDeprecation: ConfigDeprecation = ( if (settings.logging?.rotate) { addDeprecation({ configPath: 'logging.rotate', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#rolling-file-appender`, title: i18n.translate('core.deprecations.loggingRotate.deprecationTitle', { defaultMessage: `Setting "logging.rotate" is deprecated`, @@ -518,6 +536,7 @@ const logEventsLogDeprecation: ConfigDeprecation = ( if (settings.logging?.events?.log) { addDeprecation({ configPath: 'logging.events.log', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingevents`, title: i18n.translate('core.deprecations.loggingEventsLog.deprecationTitle', { defaultMessage: `Setting "logging.events.log" is deprecated`, @@ -548,6 +567,7 @@ const logEventsErrorDeprecation: ConfigDeprecation = ( if (settings.logging?.events?.error) { addDeprecation({ configPath: 'logging.events.error', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingevents`, title: i18n.translate('core.deprecations.loggingEventsError.deprecationTitle', { defaultMessage: `Setting "logging.events.error" is deprecated`, @@ -578,6 +598,7 @@ const logFilterDeprecation: ConfigDeprecation = ( if (settings.logging?.filter) { addDeprecation({ configPath: 'logging.filter', + level: 'critical', documentationUrl: `https://github.com/elastic/kibana/blob/${branch}/src/core/server/logging/README.mdx#loggingfilter`, title: i18n.translate('core.deprecations.loggingFilter.deprecationTitle', { defaultMessage: `Setting "logging.filter" is deprecated`, @@ -596,40 +617,42 @@ export const coreDeprecationProvider: ConfigDeprecationProvider = ({ unusedFromRoot, renameFromRoot, }) => [ - unusedFromRoot('savedObjects.indexCheckTimeout'), - unusedFromRoot('server.xsrf.token'), - unusedFromRoot('maps.manifestServiceUrl'), - unusedFromRoot('optimize.lazy'), - unusedFromRoot('optimize.lazyPort'), - unusedFromRoot('optimize.lazyHost'), - unusedFromRoot('optimize.lazyPrebuild'), - unusedFromRoot('optimize.lazyProxyTimeout'), - unusedFromRoot('optimize.enabled'), - unusedFromRoot('optimize.bundleFilter'), - unusedFromRoot('optimize.bundleDir'), - unusedFromRoot('optimize.viewCaching'), - unusedFromRoot('optimize.watch'), - unusedFromRoot('optimize.watchPort'), - unusedFromRoot('optimize.watchHost'), - unusedFromRoot('optimize.watchPrebuild'), - unusedFromRoot('optimize.watchProxyTimeout'), - unusedFromRoot('optimize.useBundleCache'), - unusedFromRoot('optimize.sourceMaps'), - unusedFromRoot('optimize.workers'), - unusedFromRoot('optimize.profile'), - unusedFromRoot('optimize.validateSyntaxOfNodeModules'), - renameFromRoot('xpack.xpack_main.telemetry.config', 'telemetry.config'), - renameFromRoot('xpack.xpack_main.telemetry.url', 'telemetry.url'), - renameFromRoot('xpack.xpack_main.telemetry.enabled', 'telemetry.enabled'), - renameFromRoot('xpack.telemetry.enabled', 'telemetry.enabled'), - renameFromRoot('xpack.telemetry.config', 'telemetry.config'), - renameFromRoot('xpack.telemetry.banner', 'telemetry.banner'), - renameFromRoot('xpack.telemetry.url', 'telemetry.url'), - renameFromRoot('cpu.cgroup.path.override', 'ops.cGroupOverrides.cpuPath'), - renameFromRoot('cpuacct.cgroup.path.override', 'ops.cGroupOverrides.cpuAcctPath'), - renameFromRoot('server.xsrf.whitelist', 'server.xsrf.allowlist'), - unusedFromRoot('elasticsearch.preserveHost'), - unusedFromRoot('elasticsearch.startupTimeout'), + unusedFromRoot('savedObjects.indexCheckTimeout', { level: 'critical' }), + unusedFromRoot('server.xsrf.token', { level: 'critical' }), + unusedFromRoot('maps.manifestServiceUrl', { level: 'critical' }), + unusedFromRoot('optimize.lazy', { level: 'critical' }), + unusedFromRoot('optimize.lazyPort', { level: 'critical' }), + unusedFromRoot('optimize.lazyHost', { level: 'critical' }), + unusedFromRoot('optimize.lazyPrebuild', { level: 'critical' }), + unusedFromRoot('optimize.lazyProxyTimeout', { level: 'critical' }), + unusedFromRoot('optimize.enabled', { level: 'critical' }), + unusedFromRoot('optimize.bundleFilter', { level: 'critical' }), + unusedFromRoot('optimize.bundleDir', { level: 'critical' }), + unusedFromRoot('optimize.viewCaching', { level: 'critical' }), + unusedFromRoot('optimize.watch', { level: 'critical' }), + unusedFromRoot('optimize.watchPort', { level: 'critical' }), + unusedFromRoot('optimize.watchHost', { level: 'critical' }), + unusedFromRoot('optimize.watchPrebuild', { level: 'critical' }), + unusedFromRoot('optimize.watchProxyTimeout', { level: 'critical' }), + unusedFromRoot('optimize.useBundleCache', { level: 'critical' }), + unusedFromRoot('optimize.sourceMaps', { level: 'critical' }), + unusedFromRoot('optimize.workers', { level: 'critical' }), + unusedFromRoot('optimize.profile', { level: 'critical' }), + unusedFromRoot('optimize.validateSyntaxOfNodeModules', { level: 'critical' }), + renameFromRoot('xpack.xpack_main.telemetry.config', 'telemetry.config', { level: 'critical' }), + renameFromRoot('xpack.xpack_main.telemetry.url', 'telemetry.url', { level: 'critical' }), + renameFromRoot('xpack.xpack_main.telemetry.enabled', 'telemetry.enabled', { level: 'critical' }), + renameFromRoot('xpack.telemetry.enabled', 'telemetry.enabled', { level: 'critical' }), + renameFromRoot('xpack.telemetry.config', 'telemetry.config', { level: 'critical' }), + renameFromRoot('xpack.telemetry.banner', 'telemetry.banner', { level: 'critical' }), + renameFromRoot('xpack.telemetry.url', 'telemetry.url', { level: 'critical' }), + renameFromRoot('cpu.cgroup.path.override', 'ops.cGroupOverrides.cpuPath', { level: 'critical' }), + renameFromRoot('cpuacct.cgroup.path.override', 'ops.cGroupOverrides.cpuAcctPath', { + level: 'critical', + }), + renameFromRoot('server.xsrf.whitelist', 'server.xsrf.allowlist', { level: 'critical' }), + unusedFromRoot('elasticsearch.preserveHost', { level: 'critical' }), + unusedFromRoot('elasticsearch.startupTimeout', { level: 'critical' }), rewriteCorsSettings, configPathDeprecation, kibanaPathConf, diff --git a/src/core/server/kibana_config.ts b/src/core/server/kibana_config.ts index 859f25d7082f1..a0476a54ae744 100644 --- a/src/core/server/kibana_config.ts +++ b/src/core/server/kibana_config.ts @@ -18,6 +18,7 @@ const deprecations: ConfigDeprecationProvider = () => [ if (kibana?.index) { addDeprecation({ configPath: 'kibana.index', + level: 'critical', title: i18n.translate('core.kibana.index.deprecationTitle', { defaultMessage: `Setting "kibana.index" is deprecated`, }), diff --git a/src/plugins/home/server/index.ts b/src/plugins/home/server/index.ts index 9523766596fed..db72af9d78a39 100644 --- a/src/plugins/home/server/index.ts +++ b/src/plugins/home/server/index.ts @@ -19,7 +19,9 @@ export const config: PluginConfigDescriptor = { }, schema: configSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('kibana.disableWelcomeScreen', 'home.disableWelcomeScreen'), + renameFromRoot('kibana.disableWelcomeScreen', 'home.disableWelcomeScreen', { + level: 'critical', + }), ], }; diff --git a/src/plugins/newsfeed/server/index.ts b/src/plugins/newsfeed/server/index.ts index 460d48622af69..30bd27f027ccd 100644 --- a/src/plugins/newsfeed/server/index.ts +++ b/src/plugins/newsfeed/server/index.ts @@ -17,7 +17,7 @@ export const config: PluginConfigDescriptor = { mainInterval: true, fetchInterval: true, }, - deprecations: ({ unused }) => [unused('defaultLanguage')], + deprecations: ({ unused }) => [unused('defaultLanguage', { level: 'critical' })], }; export function plugin() { diff --git a/src/plugins/telemetry/server/config/deprecations.test.ts b/src/plugins/telemetry/server/config/deprecations.test.ts index 567ef69e8991c..1243ae6712057 100644 --- a/src/plugins/telemetry/server/config/deprecations.test.ts +++ b/src/plugins/telemetry/server/config/deprecations.test.ts @@ -165,6 +165,7 @@ describe('deprecateEndpointConfigs', () => { "To send usage to the staging endpoint add \\"telemetry.sendUsageTo: staging\\" to the Kibana configuration.", ], }, + "level": "critical", "message": "\\"telemetry.url\\" has been deprecated. Set \\"telemetry.sendUsageTo: staging\\" to the Kibana configurations to send usage to the staging endpoint.", "title": "Setting \\"telemetry.url\\" is deprecated", }, @@ -188,6 +189,7 @@ describe('deprecateEndpointConfigs', () => { "To send usage to the staging endpoint add \\"telemetry.sendUsageTo: staging\\" to the Kibana configuration.", ], }, + "level": "critical", "message": "\\"telemetry.optInStatusUrl\\" has been deprecated. Set \\"telemetry.sendUsageTo: staging\\" to the Kibana configurations to send usage to the staging endpoint.", "title": "Setting \\"telemetry.optInStatusUrl\\" is deprecated", }, diff --git a/src/plugins/telemetry/server/config/deprecations.ts b/src/plugins/telemetry/server/config/deprecations.ts index 38553be7d5774..fa939482a5f57 100644 --- a/src/plugins/telemetry/server/config/deprecations.ts +++ b/src/plugins/telemetry/server/config/deprecations.ts @@ -36,6 +36,7 @@ export const deprecateEndpointConfigs: ConfigDeprecation = ( addDeprecation({ configPath: fullConfigPath, + level: 'critical', title: i18n.translate('telemetry.endpointConfigs.deprecationTitle', { defaultMessage: 'Setting "{configPath}" is deprecated', values: { configPath: fullConfigPath }, diff --git a/src/plugins/usage_collection/server/config.ts b/src/plugins/usage_collection/server/config.ts index faf8ce7535e8a..0c52f4525c134 100644 --- a/src/plugins/usage_collection/server/config.ts +++ b/src/plugins/usage_collection/server/config.ts @@ -30,10 +30,16 @@ export type ConfigType = TypeOf; export const config: PluginConfigDescriptor = { schema: configSchema, deprecations: ({ renameFromRoot }) => [ - renameFromRoot('ui_metric.enabled', 'usageCollection.uiCounters.enabled'), - renameFromRoot('ui_metric.debug', 'usageCollection.uiCounters.debug'), - renameFromRoot('usageCollection.uiMetric.enabled', 'usageCollection.uiCounters.enabled'), - renameFromRoot('usageCollection.uiMetric.debug', 'usageCollection.uiCounters.debug'), + renameFromRoot('ui_metric.enabled', 'usageCollection.uiCounters.enabled', { + level: 'critical', + }), + renameFromRoot('ui_metric.debug', 'usageCollection.uiCounters.debug', { level: 'critical' }), + renameFromRoot('usageCollection.uiMetric.enabled', 'usageCollection.uiCounters.enabled', { + level: 'critical', + }), + renameFromRoot('usageCollection.uiMetric.debug', 'usageCollection.uiCounters.debug', { + level: 'critical', + }), ], exposeToBrowser: { uiCounters: true, diff --git a/x-pack/plugins/licensing/server/licensing_config.ts b/x-pack/plugins/licensing/server/licensing_config.ts index a27eaba56df50..485351ec22048 100644 --- a/x-pack/plugins/licensing/server/licensing_config.ts +++ b/x-pack/plugins/licensing/server/licensing_config.ts @@ -21,7 +21,8 @@ export const config: PluginConfigDescriptor = { deprecations: ({ renameFromRoot }) => [ renameFromRoot( 'xpack.xpack_main.xpack_api_polling_frequency_millis', - 'xpack.licensing.api_polling_frequency' + 'xpack.licensing.api_polling_frequency', + { level: 'critical' } ), ], };