diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts index 10128470005ce..393b89a97acb2 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts @@ -43,6 +43,7 @@ const STORYBOOKS = [ 'lists', 'observability', 'observability_ai_assistant', + 'observability_shared', 'presentation', 'security_solution', 'security_solution_packages', diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index ab71ff97619fa..16594dbc49157 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -56,6 +56,7 @@ export const storybookAliases = { 'x-pack/plugins/observability_solution/observability_ai_assistant/.storybook', observability_ai_assistant_app: 'x-pack/plugins/observability_solution/observability_ai_assistant_app/.storybook', + observability_shared: 'x-pack/plugins/observability_solution/observability_shared/.storybook', observability_slo: 'x-pack/plugins/observability_solution/slo/.storybook', presentation: 'src/plugins/presentation_util/storybook', random_sampling: 'x-pack/packages/kbn-random-sampling/.storybook', diff --git a/x-pack/plugins/observability_solution/apm/public/components/app/service_overview/service_overview.stories.tsx b/x-pack/plugins/observability_solution/apm/public/components/app/service_overview/service_overview.stories.tsx index 6bbad7a95e114..afd0b06700517 100644 --- a/x-pack/plugins/observability_solution/apm/public/components/app/service_overview/service_overview.stories.tsx +++ b/x-pack/plugins/observability_solution/apm/public/components/app/service_overview/service_overview.stories.tsx @@ -7,12 +7,12 @@ import { Meta, Story } from '@storybook/react'; import React from 'react'; +import { EntityDataStreamType } from '@kbn/observability-shared-plugin/common'; import { ServiceOverview } from '.'; import { MockApmPluginStorybook } from '../../../context/apm_plugin/mock_apm_plugin_storybook'; import { APMServiceContextValue } from '../../../context/apm_service/apm_service_context'; import { FETCH_STATUS } from '../../../hooks/use_fetcher'; import { mockApmApiCallResponse } from '../../../services/rest/call_apm_api_spy'; -import { EntityDataStreamType } from '../../../../common/entities/types'; const stories: Meta<{}> = { title: 'app/ServiceOverview', diff --git a/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts b/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts index 89d5c3ff49114..3ba12c66e8137 100644 --- a/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts +++ b/x-pack/plugins/observability_solution/apm/public/utils/get_signal_type.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { EntityDataStreamType } from '../../common/entities/types'; +import { EntityDataStreamType } from '@kbn/observability-shared-plugin/common'; export function isApmSignal(dataStreamTypes: EntityDataStreamType[]) { return ( diff --git a/x-pack/plugins/observability_solution/apm/server/routes/entities/utils/merge_entities.ts b/x-pack/plugins/observability_solution/apm/server/routes/entities/utils/merge_entities.ts index c7269989a3564..e2ccc270b2a86 100644 --- a/x-pack/plugins/observability_solution/apm/server/routes/entities/utils/merge_entities.ts +++ b/x-pack/plugins/observability_solution/apm/server/routes/entities/utils/merge_entities.ts @@ -6,9 +6,9 @@ */ import { compact, uniq } from 'lodash'; +import { EntityDataStreamType } from '@kbn/observability-shared-plugin/common'; import type { EntityLatestServiceRaw } from '../types'; import type { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; -import type { EntityDataStreamType } from '../../../../common/entities/types'; export interface MergedServiceEntity { serviceName: string; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/constants.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/constants.ts new file mode 100644 index 0000000000000..ca56a58875220 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/constants.ts @@ -0,0 +1,111 @@ +/* + * 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 { ObservabilityOnboardingLocatorParams } from '@kbn/deeplinks-observability'; +import { i18n } from '@kbn/i18n'; +import { AddDataPanelProps } from '@kbn/observability-shared-plugin/public'; +import { LocatorPublic } from '@kbn/share-plugin/common'; +import { OnboardingFlow } from '../../shared/templates/no_data_config'; + +export type AddMetricsCalloutKey = + | 'hostOverview' + | 'hostMetrics' + | 'hostProcesses' + | 'containerOverview' + | 'containerMetrics'; + +const defaultPrimaryActionLabel = i18n.translate( + 'xpack.infra.addDataCallout.hostOverviewPrimaryActionLabel', + { + defaultMessage: 'Add Metrics', + } +); + +const defaultContent = { + content: { + title: i18n.translate('xpack.infra.addDataCallout.defaultTitle', { + defaultMessage: 'View core metrics to understand your host performance', + }), + content: i18n.translate('xpack.infra.addDataCallout.defaultContent', { + defaultMessage: + 'Collect metrics such as CPU and memory usage to identify performance bottlenecks that could be affecting your users.', + }), + }, +}; + +const hostDefaultActions = ( + locator: LocatorPublic | undefined +) => { + return { + actions: { + primary: { + href: locator?.getRedirectUrl({ category: OnboardingFlow.Hosts }), + label: defaultPrimaryActionLabel, + }, + secondary: { + href: 'https://ela.st/demo-cluster-hosts', + }, + link: { + href: 'https://ela.st/docs-hosts-add-metrics', + }, + }, + }; +}; + +const containerDefaultActions = ( + locator: LocatorPublic | undefined +) => { + return { + actions: { + primary: { + href: locator?.getRedirectUrl({ category: OnboardingFlow.Infra }), + label: defaultPrimaryActionLabel, + }, + link: { + href: 'https://ela.st/docs-containers-add-metrics', + }, + }, + }; +}; + +export const addMetricsCalloutDefinitions = ( + locator: LocatorPublic | undefined +): Record< + AddMetricsCalloutKey, + Omit +> => { + return { + hostOverview: { + ...defaultContent, + ...hostDefaultActions(locator), + }, + hostMetrics: { + ...defaultContent, + ...hostDefaultActions(locator), + }, + hostProcesses: { + content: { + title: i18n.translate('xpack.infra.addDataCallout.hostProcessesTitle', { + defaultMessage: 'View host processes to identify performance bottlenecks', + }), + content: i18n.translate('xpack.infra.addDataCallout.hostProcessesContent', { + defaultMessage: + 'Collect process data to understand what is consuming resource on your hosts.', + }), + }, + ...hostDefaultActions(locator), + }, + containerOverview: { + ...defaultContent, + ...containerDefaultActions(locator), + }, + containerMetrics: { + ...defaultContent, + ...containerDefaultActions(locator), + }, + }; +}; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/index.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/index.tsx new file mode 100644 index 0000000000000..c4132a1e29a3a --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/add_metrics_callout/index.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AddDataPanel } from '@kbn/observability-shared-plugin/public'; +import { + OBSERVABILITY_ONBOARDING_LOCATOR, + ObservabilityOnboardingLocatorParams, +} from '@kbn/deeplinks-observability'; +import { AddMetricsCalloutEventParams } from '../../../services/telemetry'; +import { addMetricsCalloutDefinitions, AddMetricsCalloutKey } from './constants'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +export interface AddMetricsCalloutProps { + id: AddMetricsCalloutKey; + onDismiss?: () => void; +} + +const defaultEventParams: AddMetricsCalloutEventParams = { view: 'add_metrics_cta' }; + +export function AddMetricsCallout({ id, onDismiss }: AddMetricsCalloutProps) { + const { + services: { telemetry, share }, + } = useKibanaContextForPlugin(); + + const onboardingLocator = share.url.locators.get( + OBSERVABILITY_ONBOARDING_LOCATOR + ); + + function handleAddMetricsClick() { + telemetry.reportAddMetricsCalloutAddMetricsClicked(defaultEventParams); + } + + function handleTryItClick() { + telemetry.reportAddMetricsCalloutTryItClicked(defaultEventParams); + } + + function handleLearnMoreClick() { + telemetry.reportAddMetricsCalloutLearnMoreClicked(defaultEventParams); + } + + function handleDismiss() { + telemetry.reportAddMetricsCalloutDismissed(defaultEventParams); + onDismiss?.(); + } + + return ( + + ); +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.ts new file mode 100644 index 0000000000000..349b8e13ae7ab --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/hooks/use_entity_summary.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 * as z from '@kbn/zod'; +import { EntityDataStreamType, EntityType } from '@kbn/observability-shared-plugin/common'; +import { useFetcher } from '../../../hooks/use_fetcher'; + +const EntityTypeSchema = z.union([z.literal(EntityType.HOST), z.literal(EntityType.CONTAINER)]); +const EntityDataStreamSchema = z.union([ + z.literal(EntityDataStreamType.METRICS), + z.literal(EntityDataStreamType.LOGS), +]); + +const EntitySummarySchema = z.object({ + entityType: EntityTypeSchema, + entityId: z.string(), + sourceDataStreams: z.array(EntityDataStreamSchema), +}); + +export type EntitySummary = z.infer; + +export function useEntitySummary({ + entityType, + entityId, +}: { + entityType: string; + entityId: string; +}) { + const { data, status } = useFetcher( + async (callApi) => { + if (!entityType || !entityId) { + return undefined; + } + + const response = await callApi(`/api/infra/entities/${entityType}/${entityId}/summary`, { + method: 'GET', + }); + + return EntitySummarySchema.parse(response); + }, + [entityType, entityId] + ); + + return { dataStreams: data?.sourceDataStreams ?? [], status }; +} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/metrics/metrics_template.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/metrics/metrics_template.tsx index 02d61f1348cad..9206f4cc188e2 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/metrics/metrics_template.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/metrics/metrics_template.tsx @@ -22,16 +22,25 @@ import { useResizeObserver, EuiListGroup, EuiListGroupItem, + EuiSpacer, } from '@elastic/eui'; import { css, cx } from '@emotion/css'; import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props'; import { useTabSwitcherContext } from '../../hooks/use_tab_switcher'; +import { AddMetricsCalloutKey } from '../../add_metrics_callout/constants'; +import { AddMetricsCallout } from '../../add_metrics_callout'; +import { useEntitySummary } from '../../hooks/use_entity_summary'; +import { isMetricsSignal } from '../../utils/get_data_stream_types'; export const MetricsTemplate = React.forwardRef( ({ children }, ref) => { const { euiTheme } = useEuiTheme(); - const { renderMode } = useAssetDetailsRenderPropsContext(); + const { asset, renderMode } = useAssetDetailsRenderPropsContext(); const { scrollTo, setScrollTo } = useTabSwitcherContext(); + const { dataStreams, status: dataStreamsStatus } = useEntitySummary({ + entityType: asset.type, + entityId: asset.id, + }); const scrollTimeoutRef = useRef(null); const initialScrollTimeoutRef = useRef(null); @@ -111,94 +120,110 @@ export const MetricsTemplate = React.forwardRef - + {showAddMetricsCallout && ( + <> + + + + )} + -
- - {quickAccessItems.map(([sectionId, label]) => ( - onQuickAccessItemClick(sectionId)} - color="text" - size="s" - className={cx({ - [css` - text-decoration: underline; - `]: sectionId === scrollTo, - })} - css={css` - background-color: unset; - & > button { - padding-block: ${euiTheme.size.s}; - padding-inline: 0px; - } - &:hover, - &:focus-within { - background-color: unset; - } - `} - label={label} - /> - ))} - -
-
- - [data-section-id] { - scroll-margin-top: ${quickAccessOffset}; + position: sticky; + top: ${kibanaHeaderOffset}; + background: ${euiTheme.colors.emptyShade}; + min-width: 100px; + z-index: ${euiTheme.levels.navigation}; + ${useEuiMinBreakpoint('xl')} { + align-self: flex-start; } `} > - {React.Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - return React.cloneElement(child as React.ReactElement, { - ref: setContentRef, - key: index, - }); - } - })} - - - +
+ + {quickAccessItems.map(([sectionId, label]) => ( + onQuickAccessItemClick(sectionId)} + color="text" + size="s" + className={cx({ + [css` + text-decoration: underline; + `]: sectionId === scrollTo, + })} + css={css` + background-color: unset; + & > button { + padding-block: ${euiTheme.size.s}; + padding-inline: 0px; + } + &:hover, + &:focus-within { + background-color: unset; + } + `} + label={label} + /> + ))} + +
+ + + [data-section-id] { + scroll-margin-top: ${quickAccessOffset}; + } + `} + > + {React.Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + return React.cloneElement(child as React.ReactElement, { + ref: setContentRef, + key: index, + }); + } + })} + + + + ); } ); diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/overview.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/overview.tsx index f74cef287b8c4..e4f0eee51dbc0 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/overview.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/overview/overview.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; import { css } from '@emotion/react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { MetadataSummaryList, MetadataSummaryListCompact, @@ -22,6 +23,12 @@ import { MetadataErrorCallout } from '../../components/metadata_error_callout'; import { CpuProfilingPrompt } from './kpis/cpu_profiling_prompt'; import { ServicesContent } from './services'; import { MetricsContent } from './metrics/metrics'; +import { AddMetricsCallout } from '../../add_metrics_callout'; +import { AddMetricsCalloutKey } from '../../add_metrics_callout/constants'; +import { useEntitySummary } from '../../hooks/use_entity_summary'; +import { isMetricsSignal } from '../../utils/get_data_stream_types'; +import { INTEGRATIONS } from '../../constants'; +import { useIntegrationCheck } from '../../hooks/use_integration_check'; export const Overview = () => { const { dateRange } = useDatePickerContext(); @@ -33,6 +40,20 @@ export const Overview = () => { } = useMetadataStateContext(); const { metrics } = useDataViewsContext(); const isFullPageView = renderMode.mode === 'page'; + const { dataStreams, status: dataStreamsStatus } = useEntitySummary({ + entityType: asset.type, + entityId: asset.id, + }); + const addMetricsCalloutId: AddMetricsCalloutKey = + asset.type === 'host' ? 'hostOverview' : 'containerOverview'; + const [dismissedAddMetricsCallout, setDismissedAddMetricsCallout] = useLocalStorage( + `infra.dismissedAddMetricsCallout.${addMetricsCalloutId}`, + false + ); + const isDockerContainer = useIntegrationCheck({ dependsOn: INTEGRATIONS.docker }); + const isKubernetesContainer = useIntegrationCheck({ + dependsOn: INTEGRATIONS.kubernetesContainer, + }); const metadataSummarySection = isFullPageView ? ( @@ -44,18 +65,48 @@ export const Overview = () => { /> ); + const shouldShowCallout = () => { + if ( + dataStreamsStatus !== 'success' || + renderMode.mode !== 'page' || + dismissedAddMetricsCallout + ) { + return false; + } + + const { type } = asset; + const baseCondition = !isMetricsSignal(dataStreams); + + const isRelevantContainer = + type === 'container' && (isDockerContainer || isKubernetesContainer); + + return baseCondition && (type === 'host' || isRelevantContainer); + }; + + const showAddMetricsCallout = shouldShowCallout(); + return ( - - - {asset.type === 'host' ? : null} - - + {showAddMetricsCallout ? ( + + { + setDismissedAddMetricsCallout(true); + }} + /> + + ) : ( + + + {asset.type === 'host' ? : null} + + )} {fetchMetadataError && !metadataLoading ? : metadataSummarySection} diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx index e7666a7f4191e..4e8fc1e3badb1 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/tabs/processes/processes.tsx @@ -16,11 +16,14 @@ import { Query, EuiFlexGroup, EuiFlexItem, + EuiSpacer, + EuiLoadingSpinner, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiLoadingSpinner } from '@elastic/eui'; import { getFieldByType } from '@kbn/metrics-data-access-plugin/common'; import { decodeOrThrow } from '@kbn/io-ts-utils'; +import { EntityType } from '@kbn/observability-shared-plugin/common'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { useSourceContext } from '../../../../containers/metrics_source'; import { isPending, useFetcher } from '../../../../hooks/use_fetcher'; import { parseSearchString } from './parse_search_string'; @@ -36,6 +39,10 @@ import { TopProcessesTooltip } from '../../components/top_processes_tooltip'; import { ProcessListAPIResponseRT } from '../../../../../common/http_api'; import { useRequestObservable } from '../../hooks/use_request_observable'; import { useTabSwitcherContext } from '../../hooks/use_tab_switcher'; +import { AddMetricsCalloutKey } from '../../add_metrics_callout/constants'; +import { AddMetricsCallout } from '../../add_metrics_callout'; +import { useEntitySummary } from '../../hooks/use_entity_summary'; +import { isMetricsSignal } from '../../utils/get_data_stream_types'; const options = Object.entries(STATE_NAMES).map(([value, view]: [string, string]) => ({ value, @@ -46,10 +53,19 @@ export const Processes = () => { const ref = useRef(null); const { getDateRangeInTimestamp } = useDatePickerContext(); const [urlState, setUrlState] = useAssetDetailsUrlState(); - const { asset } = useAssetDetailsRenderPropsContext(); + const { asset, renderMode } = useAssetDetailsRenderPropsContext(); const { sourceId } = useSourceContext(); const { request$ } = useRequestObservable(); const { isActiveTab } = useTabSwitcherContext(); + const { dataStreams, status: dataStreamsStatus } = useEntitySummary({ + entityType: EntityType.HOST, + entityId: asset.name, + }); + const addMetricsCalloutId: AddMetricsCalloutKey = 'hostProcesses'; + const [dismissedAddMetricsCallout, setDismissedAddMetricsCallout] = useLocalStorage( + `infra.dismissedAddMetricsCallout.${addMetricsCalloutId}`, + false + ); const [searchText, setSearchText] = useState(urlState?.processSearch ?? ''); const [searchQueryError, setSearchQueryError] = useState(null); @@ -132,105 +148,124 @@ export const Processes = () => { const isLoading = isPending(status); + const showAddMetricsCallout = + dataStreamsStatus === 'success' && + !isMetricsSignal(dataStreams) && + !dismissedAddMetricsCallout && + renderMode.mode === 'page'; + return ( - - - - - - - - - - - - - - - - - - - {!error && ( - - - {isLoading ? ( - - ) : ( - (data?.processList ?? []).length > 0 && - )} - - - )} - - - + {showAddMetricsCallout && ( + <> + { + setDismissedAddMetricsCallout(true); }} - filters={[ - { - type: 'field_value_selection', - field: 'state', - name: 'State', - operator: 'exact', - multiSelect: false, - options, - }, - ]} /> - - - {!error ? ( - + + )} + + + + - ) : ( - - - - } - actions={ - - - - } + + + + + + + + + + + + + + + {!error && ( + + + {isLoading ? ( + + ) : ( + (data?.processList ?? []).length > 0 && + )} + + + )} + + + - )} - - - + + + {!error ? ( + + ) : ( + + + + } + actions={ + + + + } + /> + )} + + + + ); }; diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/template/page.tsx b/x-pack/plugins/observability_solution/infra/public/components/asset_details/template/page.tsx index dad8ab5ce477e..363fc88c8a490 100644 --- a/x-pack/plugins/observability_solution/infra/public/components/asset_details/template/page.tsx +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/template/page.tsx @@ -23,6 +23,8 @@ import { getIntegrationsAvailable } from '../utils'; import { InfraPageTemplate } from '../../shared/templates/infra_page_template'; import { OnboardingFlow } from '../../shared/templates/no_data_config'; import { PageTitleWithPopover } from '../header/page_title_with_popover'; +import { useEntitySummary } from '../hooks/use_entity_summary'; +import { isMetricsSignal } from '../utils/get_data_stream_types'; const DATA_AVAILABILITY_PER_TYPE: Partial> = { host: [SYSTEM_INTEGRATION], @@ -34,7 +36,10 @@ export const Page = ({ tabs = [], links = [] }: ContentTemplateProps) => { const { rightSideItems, tabEntries, breadcrumbs: headerBreadcrumbs } = usePageHeader(tabs, links); const { asset } = useAssetDetailsRenderPropsContext(); const trackOnlyOnce = React.useRef(false); - + const { dataStreams } = useEntitySummary({ + entityType: asset.type, + entityId: asset.id, + }); const { activeTabId } = useTabSwitcherContext(); const { services: { telemetry }, @@ -79,6 +84,8 @@ export const Page = ({ tabs = [], links = [] }: ContentTemplateProps) => { } }, [activeTabId, asset.type, metadata, metadataLoading, telemetry]); + const showPageTitleWithPopover = asset.type === 'host' && !isMetricsSignal(dataStreams); + return ( { pageHeader={{ pageTitle: loading ? ( - ) : asset.type === 'host' ? ( + ) : showPageTitleWithPopover ? ( ) : ( asset.name diff --git a/x-pack/plugins/observability_solution/infra/public/components/asset_details/utils/get_data_stream_types.ts b/x-pack/plugins/observability_solution/infra/public/components/asset_details/utils/get_data_stream_types.ts new file mode 100644 index 0000000000000..899fd164c8e02 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/public/components/asset_details/utils/get_data_stream_types.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 { EntityDataStreamType } from '@kbn/observability-shared-plugin/common'; + +export function isMetricsSignal(dataStreamTypes: EntityDataStreamType[] = []) { + return dataStreamTypes?.includes(EntityDataStreamType.METRICS); +} diff --git a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.mock.ts b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.mock.ts index 50043320b0fd2..d5ea4958b95f2 100644 --- a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.mock.ts @@ -17,4 +17,8 @@ export const createTelemetryClientMock = (): jest.Mocked => ({ reportAssetDetailsPageViewed: jest.fn(), reportPerformanceMetricEvent: jest.fn(), reportAssetDashboardLoaded: jest.fn(), + reportAddMetricsCalloutAddMetricsClicked: jest.fn(), + reportAddMetricsCalloutTryItClicked: jest.fn(), + reportAddMetricsCalloutLearnMoreClicked: jest.fn(), + reportAddMetricsCalloutDismissed: jest.fn(), }); diff --git a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts index 49c606419b702..d5dcf9d3f0c8d 100644 --- a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts +++ b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_client.ts @@ -8,6 +8,7 @@ import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; import { reportPerformanceMetricEvent } from '@kbn/ebt-tools'; import { + AddMetricsCalloutEventParams, AssetDashboardLoadedParams, AssetDetailsFlyoutViewedParams, AssetDetailsPageViewedParams, @@ -91,4 +92,26 @@ export class TelemetryClient implements ITelemetryClient { ...innerEvents, }); }; + + public reportAddMetricsCalloutAddMetricsClicked = (params: AddMetricsCalloutEventParams) => { + this.analytics.reportEvent( + InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_ADD_METRICS_CLICKED, + params + ); + }; + + public reportAddMetricsCalloutTryItClicked = (params: AddMetricsCalloutEventParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_TRY_IT_CLICKED, params); + }; + + public reportAddMetricsCalloutLearnMoreClicked = (params: AddMetricsCalloutEventParams) => { + this.analytics.reportEvent( + InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_LEARN_MORE_CLICKED, + params + ); + }; + + public reportAddMetricsCalloutDismissed = (params: AddMetricsCalloutEventParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_DISMISSED, params); + }; } diff --git a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_events.ts index 39b2389e71a44..ec2f918354cbf 100644 --- a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_events.ts +++ b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_events.ts @@ -216,6 +216,54 @@ const assetDashboardLoaded: InfraTelemetryEvent = { }, }; +const addMetricsCalloutAddMetricsClicked: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_ADD_METRICS_CLICKED, + schema: { + view: { + type: 'keyword', + _meta: { + description: 'Where the action was initiated (add_metrics_cta)', + }, + }, + }, +}; + +const addMetricsCalloutTryItClicked: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_TRY_IT_CLICKED, + schema: { + view: { + type: 'keyword', + _meta: { + description: 'Where the action was initiated (add_metrics_cta)', + }, + }, + }, +}; + +const addMetricsCalloutLearnMoreClicked: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_LEARN_MORE_CLICKED, + schema: { + view: { + type: 'keyword', + _meta: { + description: 'Where the action was initiated (add_metrics_cta)', + }, + }, + }, +}; + +const addMetricsCalloutDismissed: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_DISMISSED, + schema: { + view: { + type: 'keyword', + _meta: { + description: 'Where the action was initiated (add_metrics_cta)', + }, + }, + }, +}; + export const infraTelemetryEvents = [ assetDetailsFlyoutViewed, assetDetailsPageViewed, @@ -225,4 +273,8 @@ export const infraTelemetryEvents = [ hostFlyoutAddFilter, hostViewTotalHostCountRetrieved, assetDashboardLoaded, + addMetricsCalloutAddMetricsClicked, + addMetricsCalloutTryItClicked, + addMetricsCalloutLearnMoreClicked, + addMetricsCalloutDismissed, ]; diff --git a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts index 5862b20863ad5..78f4d0e64d792 100644 --- a/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts +++ b/x-pack/plugins/observability_solution/infra/public/services/telemetry/telemetry_service.test.ts @@ -261,4 +261,86 @@ describe('TelemetryService', () => { ); }); }); + + describe('#reportAddMetricsCalloutAddMetricsClicked', () => { + it('should report add metrics callout add data click with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + const view = 'testView'; + + telemetry.reportAddMetricsCalloutAddMetricsClicked({ view }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + 'Add Metrics Callout Add Metrics Clicked', + { + view, + } + ); + }); + }); + + describe('#reportAddMetricsCalloutTryItClicked', () => { + it('should report add metrics callout try it click with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + const view = 'testView'; + + telemetry.reportAddMetricsCalloutTryItClicked({ + view, + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + 'Add Metrics Callout Try It Clicked', + { + view, + } + ); + }); + }); + + describe('#reportAddMetricsCalloutLearnMoreClicked', () => { + it('should report add metrics callout learn more click with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + const view = 'testView'; + + telemetry.reportAddMetricsCalloutLearnMoreClicked({ + view, + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + 'Add Metrics Callout Learn More Clicked', + { + view, + } + ); + }); + }); + + describe('#reportAddMetricsCalloutDismissed', () => { + it('should report add metrics callout dismiss click with properties', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + const view = 'testView'; + + telemetry.reportAddMetricsCalloutDismissed({ + view, + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + 'Add Metrics Callout Dismissed', + { + view, + } + ); + }); + }); }); diff --git a/x-pack/plugins/observability_solution/infra/public/services/telemetry/types.ts b/x-pack/plugins/observability_solution/infra/public/services/telemetry/types.ts index ae1e95c568d47..14816421fe695 100644 --- a/x-pack/plugins/observability_solution/infra/public/services/telemetry/types.ts +++ b/x-pack/plugins/observability_solution/infra/public/services/telemetry/types.ts @@ -22,6 +22,10 @@ export enum InfraTelemetryEventTypes { ASSET_DETAILS_FLYOUT_VIEWED = 'Asset Details Flyout Viewed', ASSET_DETAILS_PAGE_VIEWED = 'Asset Details Page Viewed', ASSET_DASHBOARD_LOADED = 'Asset Dashboard Loaded', + ADD_METRICS_CALLOUT_ADD_METRICS_CLICKED = 'Add Metrics Callout Add Metrics Clicked', + ADD_METRICS_CALLOUT_TRY_IT_CLICKED = 'Add Metrics Callout Try It Clicked', + ADD_METRICS_CALLOUT_LEARN_MORE_CLICKED = 'Add Metrics Callout Learn More Clicked', + ADD_METRICS_CALLOUT_DISMISSED = 'Add Metrics Callout Dismissed', } export interface HostsViewQuerySubmittedParams { @@ -61,13 +65,18 @@ export interface AssetDashboardLoadedParams { filtered_by?: string[]; } +export interface AddMetricsCalloutEventParams { + view: string; +} + export type InfraTelemetryEventParams = | HostsViewQuerySubmittedParams | HostEntryClickedParams | HostFlyoutFilterActionParams | HostsViewQueryHostsCountRetrievedParams | AssetDetailsFlyoutViewedParams - | AssetDashboardLoadedParams; + | AssetDashboardLoadedParams + | AddMetricsCalloutEventParams; export interface PerformanceMetricInnerEvents { key1?: string; @@ -89,6 +98,10 @@ export interface ITelemetryClient { meta: Record ): void; reportAssetDashboardLoaded(params: AssetDashboardLoadedParams): void; + reportAddMetricsCalloutAddMetricsClicked(params: AddMetricsCalloutEventParams): void; + reportAddMetricsCalloutTryItClicked(params: AddMetricsCalloutEventParams): void; + reportAddMetricsCalloutLearnMoreClicked(params: AddMetricsCalloutEventParams): void; + reportAddMetricsCalloutDismissed(params: AddMetricsCalloutEventParams): void; } export type InfraTelemetryEvent = @@ -123,4 +136,20 @@ export type InfraTelemetryEvent = | { eventType: InfraTelemetryEventTypes.ASSET_DASHBOARD_LOADED; schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_ADD_METRICS_CLICKED; + schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_LEARN_MORE_CLICKED; + schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_TRY_IT_CLICKED; + schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.ADD_METRICS_CALLOUT_DISMISSED; + schema: RootSchema; }; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts new file mode 100644 index 0000000000000..c66416331e4d0 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.test.ts @@ -0,0 +1,136 @@ +/* + * 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 { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { type EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; +import { type InfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; +import { getDataStreamTypes } from './get_data_stream_types'; +import { getHasMetricsData } from './get_has_metrics_data'; +import { getLatestEntity } from './get_latest_entity'; + +jest.mock('./get_has_metrics_data', () => ({ + getHasMetricsData: jest.fn(), +})); + +jest.mock('./get_latest_entity', () => ({ + getLatestEntity: jest.fn(), +})); + +type EntityType = 'host' | 'container'; + +describe('getDataStreamTypes', () => { + let infraMetricsClient: jest.Mocked; + let obsEsClient: jest.Mocked; + let entityManagerClient: jest.Mocked; + + beforeEach(() => { + infraMetricsClient = {} as jest.Mocked; + obsEsClient = {} as jest.Mocked; + entityManagerClient = {} as jest.Mocked; + jest.clearAllMocks(); + }); + + it('should return only metrics when entityCentriExperienceEnabled is false and hasMetricsData is true', async () => { + (getHasMetricsData as jest.Mock).mockResolvedValue(true); + + const params = { + entityId: 'entity123', + entityType: 'host' as EntityType, + entityCentriExperienceEnabled: false, + infraMetricsClient, + obsEsClient, + entityManagerClient, + }; + + const result = await getDataStreamTypes(params); + + expect(result).toEqual(['metrics']); + expect(getHasMetricsData).toHaveBeenCalledWith({ + infraMetricsClient, + entityId: 'entity123', + field: 'host.name', + }); + }); + + it('should return an empty array when entityCentriExperienceEnabled is false and hasMetricsData is false', async () => { + (getHasMetricsData as jest.Mock).mockResolvedValue(false); + + const params = { + entityId: 'entity123', + entityType: 'container' as EntityType, + entityCentriExperienceEnabled: false, + infraMetricsClient, + obsEsClient, + entityManagerClient, + }; + + const result = await getDataStreamTypes(params); + expect(result).toEqual([]); + }); + + it('should return metrics and entity source_data_stream types when entityCentriExperienceEnabled is true and has entity data', async () => { + (getHasMetricsData as jest.Mock).mockResolvedValue(true); + (getLatestEntity as jest.Mock).mockResolvedValue({ + 'source_data_stream.type': ['logs', 'metrics'], + }); + + const params = { + entityId: 'entity123', + entityType: 'host' as EntityType, + entityCentriExperienceEnabled: true, + infraMetricsClient, + obsEsClient, + entityManagerClient, + }; + + const result = await getDataStreamTypes(params); + + expect(result).toEqual(['metrics', 'logs']); + expect(getHasMetricsData).toHaveBeenCalled(); + expect(getLatestEntity).toHaveBeenCalledWith({ + inventoryEsClient: obsEsClient, + entityId: 'entity123', + entityType: 'host', + entityManagerClient, + }); + }); + + it('should return only metrics when entityCentriExperienceEnabled is true but entity data is undefined', async () => { + (getHasMetricsData as jest.Mock).mockResolvedValue(true); + (getLatestEntity as jest.Mock).mockResolvedValue(undefined); + + const params = { + entityId: 'entity123', + entityType: 'host' as EntityType, + entityCentriExperienceEnabled: true, + infraMetricsClient, + obsEsClient, + entityManagerClient, + }; + + const result = await getDataStreamTypes(params); + expect(result).toEqual(['metrics']); + }); + + it('should return entity source_data_stream types when has no metrics', async () => { + (getHasMetricsData as jest.Mock).mockResolvedValue(false); + (getLatestEntity as jest.Mock).mockResolvedValue({ + 'source_data_stream.type': ['logs', 'traces'], + }); + + const params = { + entityId: 'entity123', + entityType: 'host' as EntityType, + entityCentriExperienceEnabled: true, + infraMetricsClient, + obsEsClient, + entityManagerClient, + }; + + const result = await getDataStreamTypes(params); + expect(result).toEqual(['logs', 'traces']); + }); +}); diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts new file mode 100644 index 0000000000000..3218ae257f1a2 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_data_stream_types.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; +import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common'; +import { + EntityDataStreamType, + SOURCE_DATA_STREAM_TYPE, +} from '@kbn/observability-shared-plugin/common'; +import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { type InfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; +import { getHasMetricsData } from './get_has_metrics_data'; +import { getLatestEntity } from './get_latest_entity'; + +interface Params { + entityId: string; + entityType: 'host' | 'container'; + entityCentriExperienceEnabled: boolean; + infraMetricsClient: InfraMetricsClient; + obsEsClient: ObservabilityElasticsearchClient; + entityManagerClient: EntityClient; +} + +export async function getDataStreamTypes({ + entityCentriExperienceEnabled, + entityId, + entityManagerClient, + entityType, + infraMetricsClient, + obsEsClient, +}: Params) { + const hasMetricsData = await getHasMetricsData({ + infraMetricsClient, + entityId, + field: findInventoryFields(entityType).id, + }); + + const sourceDataStreams = new Set(hasMetricsData ? [EntityDataStreamType.METRICS] : []); + + if (!entityCentriExperienceEnabled) { + return Array.from(sourceDataStreams); + } + + const entity = await getLatestEntity({ + inventoryEsClient: obsEsClient, + entityId, + entityType, + entityManagerClient, + }); + + if (entity?.[SOURCE_DATA_STREAM_TYPE]) { + [entity[SOURCE_DATA_STREAM_TYPE]].flat().forEach((item) => { + sourceDataStreams.add(item as EntityDataStreamType); + }); + } + + return Array.from(sourceDataStreams); +} diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_has_metrics_data.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_has_metrics_data.ts new file mode 100644 index 0000000000000..58389fde22f08 --- /dev/null +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_has_metrics_data.ts @@ -0,0 +1,31 @@ +/* + * 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 { termQuery } from '@kbn/observability-plugin/server'; +import { InfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; + +export async function getHasMetricsData({ + infraMetricsClient, + field, + entityId, +}: { + infraMetricsClient: InfraMetricsClient; + field: string; + entityId: string; +}) { + const results = await infraMetricsClient.search({ + allow_no_indices: true, + ignore_unavailable: true, + body: { + track_total_hits: true, + terminate_after: 1, + size: 0, + query: { bool: { filter: termQuery(field, entityId) } }, + }, + }); + return results.hits.total.value !== 0; +} diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts index 6422ab9502f55..7bcce2964fd13 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/get_latest_entity.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; -import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; -import { ENTITY_LATEST, EntityDefinition, entitiesAliasPattern } from '@kbn/entities-schema'; -import { type EntityDefinitionWithState } from '@kbn/entityManager-plugin/server/lib/entities/types'; +import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; +import { type EntityClient } from '@kbn/entityManager-plugin/server/lib/entity_client'; import { ENTITY_TYPE, SOURCE_DATA_STREAM_TYPE, } from '@kbn/observability-shared-plugin/common/field_names/elasticsearch'; +import type { ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ type: '*', @@ -27,23 +27,30 @@ export async function getLatestEntity({ inventoryEsClient, entityId, entityType, - entityDefinitions, + entityManagerClient, }: { inventoryEsClient: ObservabilityElasticsearchClient; entityType: 'host' | 'container'; entityId: string; - entityDefinitions: EntityDefinition[] | EntityDefinitionWithState[]; -}) { - const hostOrContainerIdentityField = entityDefinitions[0]?.identityFields?.[0]?.field; + entityManagerClient: EntityClient; +}): Promise { + const { definitions } = await entityManagerClient.getEntityDefinitions({ + builtIn: true, + type: entityType, + }); + + const hostOrContainerIdentityField = definitions[0]?.identityFields?.[0]?.field; if (hostOrContainerIdentityField === undefined) { - return; + return { [SOURCE_DATA_STREAM_TYPE]: [] }; } + const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { query: `FROM ${ENTITIES_LATEST_ALIAS} - | WHERE ${ENTITY_TYPE} == "${entityType}" - | WHERE ${hostOrContainerIdentityField} == "${entityId}" + | WHERE ${ENTITY_TYPE} == ? + | WHERE ${hostOrContainerIdentityField} == ? | KEEP ${SOURCE_DATA_STREAM_TYPE} `, + params: [entityType, entityId], }); return esqlResultToPlainObjects(latestEntitiesEsqlResponse)[0]; diff --git a/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts b/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts index 2482f235faccc..cb169f83f171d 100644 --- a/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts +++ b/x-pack/plugins/observability_solution/infra/server/routes/entities/index.ts @@ -7,10 +7,11 @@ import { schema } from '@kbn/config-schema'; import { METRICS_APP_ID } from '@kbn/deeplinks-observability/constants'; -import { SOURCE_DATA_STREAM_TYPE } from '@kbn/observability-shared-plugin/common/field_names/elasticsearch'; +import { entityCentricExperience } from '@kbn/observability-plugin/common'; import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; +import { getInfraMetricsClient } from '../../lib/helpers/get_infra_metrics_client'; import { InfraBackendLibs } from '../../lib/infra_types'; -import { getLatestEntity } from './get_latest_entity'; +import { getDataStreamTypes } from './get_data_stream_types'; export const initEntitiesConfigurationRoutes = (libs: InfraBackendLibs) => { const { framework, logger } = libs; @@ -33,36 +34,36 @@ export const initEntitiesConfigurationRoutes = (libs: InfraBackendLibs) => { const { entityId, entityType } = request.params; const coreContext = await requestContext.core; const infraContext = await requestContext.infra; - const entityManager = await infraContext.entityManager.getScopedClient({ request }); + const entityManagerClient = await infraContext.entityManager.getScopedClient({ request }); + const infraMetricsClient = await getInfraMetricsClient({ + request, + libs, + context: requestContext, + }); - const client = createObservabilityEsClient({ + const obsEsClient = createObservabilityEsClient({ client: coreContext.elasticsearch.client.asCurrentUser, logger, plugin: `@kbn/${METRICS_APP_ID}-plugin`, }); - try { - // Only fetch built in definitions - const { definitions } = await entityManager.getEntityDefinitions({ - builtIn: true, - type: entityType, - }); - if (definitions.length === 0) { - return response.ok({ - body: { sourceDataStreams: [], entityId, entityType }, - }); - } + const entityCentriExperienceEnabled = await coreContext.uiSettings.client.get( + entityCentricExperience + ); - const entity = await getLatestEntity({ - inventoryEsClient: client, + try { + const sourceDataStreamTypes = await getDataStreamTypes({ + entityCentriExperienceEnabled, entityId, + entityManagerClient, entityType, - entityDefinitions: definitions, + infraMetricsClient, + obsEsClient, }); return response.ok({ body: { - sourceDataStreams: [entity?.[SOURCE_DATA_STREAM_TYPE] || []].flat() as string[], + sourceDataStreams: sourceDataStreamTypes, entityId, entityType, }, diff --git a/x-pack/plugins/observability_solution/infra/tsconfig.json b/x-pack/plugins/observability_solution/infra/tsconfig.json index fea285b3a794e..2103350048e4b 100644 --- a/x-pack/plugins/observability_solution/infra/tsconfig.json +++ b/x-pack/plugins/observability_solution/infra/tsconfig.json @@ -115,7 +115,8 @@ "@kbn/core-ui-settings-common", "@kbn/entityManager-plugin", "@kbn/observability-utils", - "@kbn/entities-schema" + "@kbn/entities-schema", + "@kbn/zod" ], "exclude": ["target/**/*"] } diff --git a/x-pack/plugins/observability_solution/observability_shared/.storybook/get_mock_context.tsx b/x-pack/plugins/observability_solution/observability_shared/.storybook/get_mock_context.tsx new file mode 100644 index 0000000000000..a1b4d85aaa7af --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/.storybook/get_mock_context.tsx @@ -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 { coreMock } from '@kbn/core/public/mocks'; +import type { CoreStart } from '@kbn/core/public'; + +export type ObservabilitySharedKibanaContext = CoreStart; + +export function getMockContext(): ObservabilitySharedKibanaContext { + const coreStart = coreMock.createStart(); + + return { + ...coreStart, + }; +} diff --git a/x-pack/plugins/observability_solution/observability_shared/.storybook/preview.js b/x-pack/plugins/observability_solution/observability_shared/.storybook/preview.js index 3200746243d47..a4fde4af88837 100644 --- a/x-pack/plugins/observability_solution/observability_shared/.storybook/preview.js +++ b/x-pack/plugins/observability_solution/observability_shared/.storybook/preview.js @@ -6,5 +6,12 @@ */ import { EuiThemeProviderDecorator } from '@kbn/kibana-react-plugin/common'; +import { addDecorator } from '@storybook/react'; +import { KibanaReactStorybookDecorator } from './storybook_decorator'; +import * as jest from 'jest-mock'; export const decorators = [EuiThemeProviderDecorator]; + +window.jest = jest; + +addDecorator(KibanaReactStorybookDecorator); diff --git a/x-pack/plugins/observability_solution/observability_shared/.storybook/storybook_decorator.tsx b/x-pack/plugins/observability_solution/observability_shared/.storybook/storybook_decorator.tsx new file mode 100644 index 0000000000000..acb9d778726ad --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/.storybook/storybook_decorator.tsx @@ -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 React, { ComponentType, useMemo } from 'react'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { getMockContext, ObservabilitySharedKibanaContext } from './get_mock_context'; + +export function ObservabilitySharedContextProvider({ + context, + children, +}: { + context: ObservabilitySharedKibanaContext; + children: React.ReactNode; +}) { + return {children}; +} + +export function KibanaReactStorybookDecorator(Story: ComponentType) { + const context = useMemo(() => getMockContext(), []); + return ( + + + + ); +} diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_data_stream_types.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_data_stream_types.ts new file mode 100644 index 0000000000000..9775b1e32eae6 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_data_stream_types.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. + */ + +export enum EntityDataStreamType { + METRICS = 'metrics', + TRACES = 'traces', + LOGS = 'logs', +} diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts new file mode 100644 index 0000000000000..b905f542d3473 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/entity_types.ts @@ -0,0 +1,11 @@ +/* + * 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 enum EntityType { + HOST = 'host', + CONTAINER = 'container', +} diff --git a/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/entity/index.ts new file mode 100644 index 0000000000000..27bef43d5ff7a --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/common/entity/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 { EntityType } from './entity_types'; +export { EntityDataStreamType } from './entity_data_stream_types'; diff --git a/x-pack/plugins/observability_solution/observability_shared/common/index.ts b/x-pack/plugins/observability_solution/observability_shared/common/index.ts index a359a3d862ce9..e9be61e8fde34 100644 --- a/x-pack/plugins/observability_solution/observability_shared/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/common/index.ts @@ -217,3 +217,5 @@ export { } from './locators'; export { COMMON_OBSERVABILITY_GROUPING } from './embeddable_grouping'; + +export { EntityType, EntityDataStreamType } from './entity'; diff --git a/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/add_data_panel.stories.tsx b/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/add_data_panel.stories.tsx new file mode 100644 index 0000000000000..76442c0a4de0a --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/add_data_panel.stories.tsx @@ -0,0 +1,152 @@ +/* + * 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, { ComponentProps, ComponentType } from 'react'; +import { AddDataPanel } from '.'; + +export default { + title: 'APP/AddDataPanel', + component: AddDataPanel, + decorators: [(Story: ComponentType) => ], +}; + +const defaultFunctions = { + onDissmiss: () => alert('Dismissed'), + onAddData: () => alert('Add Data'), + onTryIt: () => alert('Try It'), + onLearnMore: () => alert('Learn More'), +}; + +const defaultContent = (imagePosition: 'inside' | 'below' = 'inside') => { + return { + content: { + title: 'Sample Title', + content: 'Sample content', + img: { + baseFolderPath: 'path/to/base/folder', + name: 'sample_image.png', + position: imagePosition, + }, + }, + }; +}; + +const defaultPrimaryAction = { + label: 'Primary Action', + href: 'https://primary-action.com', +}; + +export function Default(props: ComponentProps) { + return ; +} + +Default.args = { + ...defaultContent(), + ...defaultFunctions, + actions: { + primary: defaultPrimaryAction, + secondary: { + href: 'https://secondary-action.com', + }, + link: { + href: 'https://link-action.com', + }, + }, +} as ComponentProps; + +export function TwoActions(props: ComponentProps) { + return ; +} + +TwoActions.args = { + ...defaultContent(), + ...defaultFunctions, + actions: { + primary: defaultPrimaryAction, + link: { + href: 'https://link-action.com', + }, + }, +} as ComponentProps; + +export function ImageBelow(props: ComponentProps) { + return ; +} + +ImageBelow.args = { + ...defaultContent('below'), + ...defaultFunctions, + actions: { + primary: defaultPrimaryAction, + secondary: { + href: 'https://secondary-action.com', + }, + link: { + href: 'https://link-action.com', + }, + }, +} as ComponentProps; + +export function WithoutImage(props: ComponentProps) { + return ; +} + +WithoutImage.args = { + content: { + ...defaultContent().content, + img: undefined, + }, + ...defaultFunctions, + actions: { + primary: defaultPrimaryAction, + secondary: { + href: 'https://secondary-action.com', + }, + link: { + href: 'https://link-action.com', + }, + }, +} as ComponentProps; + +export function CustomActionLabels(props: ComponentProps) { + return ; +} + +CustomActionLabels.args = { + ...defaultContent(), + ...defaultFunctions, + actions: { + primary: defaultPrimaryAction, + secondary: { + label: 'Secondary Action', + href: 'https://secondary-action.com', + }, + link: { + label: 'Link Action', + href: 'https://link-action.com', + }, + }, +} as ComponentProps; + +export function NotDismissable(props: ComponentProps) { + return ; +} + +NotDismissable.args = { + ...defaultContent(), + ...defaultFunctions, + onDissmiss: undefined, + actions: { + primary: defaultPrimaryAction, + secondary: { + href: 'https://secondary-action.com', + }, + link: { + href: 'https://link-action.com', + }, + }, +} as ComponentProps; diff --git a/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/index.tsx b/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/index.tsx new file mode 100644 index 0000000000000..ec6e405adcb26 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_shared/public/components/add_data_panel/index.tsx @@ -0,0 +1,180 @@ +/* + * 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. + */ + +/* eslint-disable @elastic/eui/href-or-on-click */ + +import { + EuiButton, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + useEuiTheme, +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { useTheme } from '../../hooks/use_theme'; + +interface AddDataPanelContent { + title: string; + content: string; + img?: { + name: string; + baseFolderPath: string; + position: 'inside' | 'below'; + }; +} + +interface AddDataPanelButton { + href: string | undefined; + label?: string; +} + +type AddDataPanelButtonWithLabel = Required; + +export interface AddDataPanelProps { + content: AddDataPanelContent; + onDissmiss?: () => void; + onAddData: () => void; + onTryIt?: () => void; + onLearnMore: () => void; + actions: { + primary: AddDataPanelButtonWithLabel; + secondary?: AddDataPanelButton; + link: AddDataPanelButton; + }; + 'data-test-subj'?: string; +} + +const tryItDefaultLabel = i18n.translate( + 'xpack.observabilityShared.addDataPabel.tryItButtonLabel', + { + defaultMessage: 'Try it now in our demo cluster', + } +); + +const learnMoreDefaultLabel = i18n.translate( + 'xpack.observabilityShared.addDataPabel.learnMoreLinkLabel', + { + defaultMessage: 'Learn more', + } +); + +export function AddDataPanel({ + content, + actions, + onDissmiss, + onLearnMore, + onTryIt, + onAddData, + 'data-test-subj': dataTestSubj, +}: AddDataPanelProps) { + const { euiTheme } = useEuiTheme(); + const theme = useTheme(); + const imgSrc = `${content.img?.baseFolderPath}/${theme.darkMode ? 'dark' : 'light'}/${ + content.img?.name + }`; + + return ( + <> + + + + +

{content.title}

+
+ + {content.content} + + + {actions.primary.href && ( + + + {actions.primary.label} + + + )} + {actions.secondary?.href && ( + + + {actions.secondary.label || tryItDefaultLabel} + + + )} + {actions.link?.href && ( + + + {actions.link.label || learnMoreDefaultLabel} + + + )} + +
+ {content.img && content.img?.position === 'inside' && ( + + + + )} + + {onDissmiss && ( + + )} +
+
+ {content.img && content.img?.position === 'below' && ( + <> + + + + )} + + ); +} diff --git a/x-pack/plugins/observability_solution/observability_shared/public/index.ts b/x-pack/plugins/observability_solution/observability_shared/public/index.ts index b1d8f97425e3f..d732a669e45bd 100644 --- a/x-pack/plugins/observability_solution/observability_shared/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_shared/public/index.ts @@ -102,3 +102,5 @@ export { } from './components/feature_feedback_button/feature_feedback_button'; export { BottomBarActions } from './components/bottom_bar_actions/bottom_bar_actions'; export { FieldValueSelection, FieldValueSuggestions } from './components'; + +export { AddDataPanel, type AddDataPanelProps } from './components/add_data_panel'; diff --git a/x-pack/plugins/observability_solution/observability_shared/tsconfig.json b/x-pack/plugins/observability_solution/observability_shared/tsconfig.json index 6453bae28d999..f68649c85cea6 100644 --- a/x-pack/plugins/observability_solution/observability_shared/tsconfig.json +++ b/x-pack/plugins/observability_solution/observability_shared/tsconfig.json @@ -9,7 +9,8 @@ "public/**/*.json", "server/**/*", "typings/**/*", - "../../../../typings/**/*" + "../../../../typings/**/*", + ".storybook/**/*" ], "kbn_references": [ "@kbn/core", @@ -45,5 +46,5 @@ "@kbn/es-query", "@kbn/serverless", ], - "exclude": ["target/**/*"] + "exclude": ["target/**/*", ".storybook/**/*.js"] } diff --git a/x-pack/test/functional/apps/infra/helpers.ts b/x-pack/test/functional/apps/infra/helpers.ts index 48f94303a42cb..ea6aa1d9dcd0f 100644 --- a/x-pack/test/functional/apps/infra/helpers.ts +++ b/x-pack/test/functional/apps/infra/helpers.ts @@ -77,7 +77,7 @@ export function generateHostData({ }: { from: string; to: string; - hosts: Array<{ hostName: string; cpuValue: number }>; + hosts: Array<{ hostName: string; cpuValue?: number }>; }) { const range = timerange(from, to); diff --git a/x-pack/test/functional/apps/infra/node_details.ts b/x-pack/test/functional/apps/infra/node_details.ts index f88a5cfb736a9..afacc8d63c3e3 100644 --- a/x-pack/test/functional/apps/infra/node_details.ts +++ b/x-pack/test/functional/apps/infra/node_details.ts @@ -66,6 +66,12 @@ const HOSTS = [ cpuValue: 0.1, }, ]; + +const HOSTS_WITHOUT_DATA = [ + { + hostName: 'host-7', + }, +]; interface QueryParams { name?: string; alertMetric?: string; @@ -623,6 +629,67 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe('#Asset Type: host without metrics', () => { + before(async () => { + await synthEsClient.index( + generateHostData({ + from: DATE_WITH_HOSTS_DATA_FROM, + to: DATE_WITH_HOSTS_DATA_TO, + hosts: HOSTS_WITHOUT_DATA, + }) + ); + + await navigateToNodeDetails('host-1', 'host', { + name: 'host-1', + }); + + await pageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(() => synthEsClient.clean()); + + describe('Overview Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickOverviewTab(); + }); + + [ + { metric: 'cpuUsage' }, + { metric: 'normalizedLoad1m' }, + { metric: 'memoryUsage' }, + { metric: 'diskUsage' }, + ].forEach(({ metric }) => { + it(`${metric} tile should not be shown`, async () => { + await pageObjects.assetDetails.assetDetailsKPITileMissing(metric); + }); + }); + + it('should show add metrics callout', async () => { + await pageObjects.assetDetails.addMetricsCalloutExists(); + }); + }); + + describe('Metrics Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickMetricsTab(); + }); + + it('should show add metrics callout', async () => { + await pageObjects.assetDetails.addMetricsCalloutExists(); + }); + }); + + describe('Processes Tab', () => { + before(async () => { + await pageObjects.assetDetails.clickProcessesTab(); + }); + + it('should show add metrics callout', async () => { + await pageObjects.assetDetails.addMetricsCalloutExists(); + }); + }); + }); + describe('#Asset type: host with kubernetes section', () => { before(async () => { await synthEsClient.index( diff --git a/x-pack/test/functional/page_objects/asset_details.ts b/x-pack/test/functional/page_objects/asset_details.ts index 7ce90d213d234..4e58a1839fcd9 100644 --- a/x-pack/test/functional/page_objects/asset_details.ts +++ b/x-pack/test/functional/page_objects/asset_details.ts @@ -25,6 +25,11 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) { return testSubjects.existOrFail(`infraAssetDetailsHostChartsSection${metric}`); }, + // Add metrics callout + async addMetricsCalloutExists() { + return testSubjects.existOrFail('infraAddMetricsCallout'); + }, + // Overview async clickOverviewTab() { return testSubjects.click('infraAssetDetailsOverviewTab'); @@ -34,6 +39,10 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) { return testSubjects.find('infraAssetDetailsOverviewTab'); }, + async assetDetailsKPITileMissing(type: string) { + return testSubjects.missingOrFail(`infraAssetDetailsKPI${type}`); + }, + async getAssetDetailsKPITileValue(type: string) { const element = await testSubjects.find(`infraAssetDetailsKPI${type}`); const div = await element.findByClassName('echMetricText__value');