From 4c3fe718210d5fac8b87faf4872a991dc44a8439 Mon Sep 17 00:00:00 2001 From: Panagiota Mitsopoulou Date: Thu, 28 Sep 2023 20:39:37 +0200 Subject: [PATCH] [SLO] create SLO embeddable widget (#165949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves https://github.com/elastic/kibana/issues/165947 Resolves https://github.com/elastic/actionable-observability/issues/124 ### Summary This PR adds an Embeddable SLO Overview Widget to the Dashboard app. It uses a [Metric chart](https://elastic.github.io/elastic-charts/?path=/story/metric-alpha--basic) component and displays an overview of the SLO health: - name - current sli value - target - status (background color) ### ✔️ Acceptance criteria - The SLO widget should display the basic information listed above - The SLO widget should be clickable and lead to the slo detail page - The user should be able to select the SLO and filter to instanceId - The tag "url.domain:mail.co" is the partition field and instanceId value Screenshot 2023-09-21 at 21 07 23 For more information regarding the key concepts and the usage of an embeddable you can have a look at the Embeddable plugin [README](https://github.com/elastic/kibana/tree/main/src/plugins/embeddable) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/observability/kibana.jsonc | 3 +- .../slo/overview/handle_explicit_input.tsx | 55 ++++++ .../public/embeddable/slo/overview/index.ts | 8 + .../slo/overview/slo_configuration.tsx | 85 ++++++++++ .../slo/overview/slo_embeddable.tsx | 96 +++++++++++ .../slo/overview/slo_embeddable_factory.ts | 73 ++++++++ .../embeddable/slo/overview/slo_overview.tsx | 157 ++++++++++++++++++ .../embeddable/slo/overview/slo_selector.tsx | 101 +++++++++++ .../public/embeddable/slo/overview/types.ts | 15 ++ x-pack/plugins/observability/public/plugin.ts | 10 ++ x-pack/plugins/observability/tsconfig.json | 2 + 11 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/index.ts create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx create mode 100644 x-pack/plugins/observability/public/embeddable/slo/overview/types.ts diff --git a/x-pack/plugins/observability/kibana.jsonc b/x-pack/plugins/observability/kibana.jsonc index 5064e06b156e0..5410d58ae7c92 100644 --- a/x-pack/plugins/observability/kibana.jsonc +++ b/x-pack/plugins/observability/kibana.jsonc @@ -30,7 +30,8 @@ "security", "share", "unifiedSearch", - "visualizations" + "visualizations", + "dashboard", ], "optionalPlugins": ["discover", "home", "licensing", "usageCollection", "cloud", "spaces"], "requiredBundles": ["data", "kibanaReact", "kibanaUtils", "unifiedSearch", "cloudChat", "stackAlerts", "spaces"], diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx new file mode 100644 index 0000000000000..0c36b4e915c6c --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/handle_explicit_input.tsx @@ -0,0 +1,55 @@ +/* + * 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 { toMountPoint } from '@kbn/react-kibana-mount'; + +import type { CoreStart } from '@kbn/core/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { EmbeddableSloProps, SloEmbeddableInput } from './types'; + +import { ObservabilityPublicPluginsStart } from '../../..'; +import { SloConfiguration } from './slo_configuration'; +export async function resolveEmbeddableSloUserInput( + coreStart: CoreStart, + pluginStart: ObservabilityPublicPluginsStart, + input?: SloEmbeddableInput +): Promise { + const { overlays } = coreStart; + const queryClient = new QueryClient(); + return new Promise(async (resolve, reject) => { + try { + const modalSession = overlays.openModal( + toMountPoint( + + + { + modalSession.close(); + resolve(update); + }} + onCancel={() => { + modalSession.close(); + reject(); + }} + /> + + , + { i18n: coreStart.i18n, theme: coreStart.theme } + ) + ); + } catch (error) { + reject(error); + } + }); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/index.ts b/x-pack/plugins/observability/public/embeddable/slo/overview/index.ts new file mode 100644 index 0000000000000..9cc48e8c635f2 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/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 { SloOverviewEmbeddableFactoryDefinition } from './slo_embeddable_factory'; diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx new file mode 100644 index 0000000000000..cf83690800318 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_configuration.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { SloSelector } from './slo_selector'; + +import type { EmbeddableSloProps } from './types'; + +interface SloConfigurationProps { + onCreate: (props: EmbeddableSloProps) => void; + onCancel: () => void; +} + +export function SloConfiguration({ onCreate, onCancel }: SloConfigurationProps) { + const [selectedSlo, setSelectedSlo] = useState(); + const onConfirmClick = () => + onCreate({ sloId: selectedSlo?.sloId, sloInstanceId: selectedSlo?.sloInstanceId }); + const [hasError, setHasError] = useState(false); + + return ( + + + + {i18n.translate('xpack.observability.sloEmbeddable.config.sloSelector.headerTitle', { + defaultMessage: 'SLO configuration', + })} + + + + + + { + if (slo === undefined) { + setHasError(true); + } else { + setHasError(false); + } + setSelectedSlo({ sloId: slo?.id, sloInstanceId: slo?.instanceId }); + }} + /> + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx new file mode 100644 index 0000000000000..faadbcb637646 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable.tsx @@ -0,0 +1,96 @@ +/* + * 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 ReactDOM from 'react-dom'; +import { Subscription } from 'rxjs'; +import { i18n } from '@kbn/i18n'; + +import { + Embeddable as AbstractEmbeddable, + EmbeddableOutput, + IContainer, +} from '@kbn/embeddable-plugin/public'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type CoreStart, IUiSettingsClient, ApplicationStart } from '@kbn/core/public'; +import { SloOverview } from './slo_overview'; +import type { SloEmbeddableInput } from './types'; + +export const SLO_EMBEDDABLE = 'SLO_EMBEDDABLE'; + +interface SloEmbeddableDeps { + uiSettings: IUiSettingsClient; + http: CoreStart['http']; + i18n: CoreStart['i18n']; + application: ApplicationStart; +} + +export class SLOEmbeddable extends AbstractEmbeddable { + public readonly type = SLO_EMBEDDABLE; + private subscription: Subscription; + private node?: HTMLElement; + + constructor( + private readonly deps: SloEmbeddableDeps, + initialInput: SloEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + + this.subscription = new Subscription(); + this.subscription.add(this.getInput$().subscribe(() => this.reload())); + } + + setTitle(title: string) { + this.updateInput({ title }); + } + + public render(node: HTMLElement) { + this.node = node; + this.setTitle( + this.input.title || + i18n.translate('xpack.observability.sloEmbeddable.displayTitle', { + defaultMessage: 'SLO Overview', + }) + ); + this.input.lastReloadRequestTime = Date.now(); + + const { sloId, sloInstanceId } = this.getInput(); + const queryClient = new QueryClient(); + + const I18nContext = this.deps.i18n.Context; + ReactDOM.render( + + + + + + + , + node + ); + } + + public reload() { + if (this.node) { + this.render(this.node); + } + } + + public destroy() { + super.destroy(); + this.subscription.unsubscribe(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts new file mode 100644 index 0000000000000..7adb76eb9acfe --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_embeddable_factory.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import type { CoreSetup } from '@kbn/core/public'; +import { + IContainer, + EmbeddableFactoryDefinition, + EmbeddableFactory, + ErrorEmbeddable, +} from '@kbn/embeddable-plugin/public'; +import { SLOEmbeddable, SLO_EMBEDDABLE } from './slo_embeddable'; +import { ObservabilityPublicPluginsStart, ObservabilityPublicStart } from '../../..'; +import type { SloEmbeddableInput } from './types'; + +export type SloOverviewEmbeddableFactory = EmbeddableFactory; +export class SloOverviewEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = SLO_EMBEDDABLE; + + constructor( + private getStartServices: CoreSetup< + ObservabilityPublicPluginsStart, + ObservabilityPublicStart + >['getStartServices'] + ) {} + + public async isEditable() { + return true; + } + + public async getExplicitInput(): Promise> { + const [coreStart, pluginStart] = await this.getStartServices(); + try { + const { resolveEmbeddableSloUserInput } = await import('./handle_explicit_input'); + return await resolveEmbeddableSloUserInput(coreStart, pluginStart); + } catch (e) { + return Promise.reject(); + } + } + + public async create(initialInput: SloEmbeddableInput, parent?: IContainer) { + try { + const [{ uiSettings, application, http, i18n: i18nService }] = await this.getStartServices(); + return new SLOEmbeddable( + { uiSettings, application, http, i18n: i18nService }, + initialInput, + parent + ); + } catch (e) { + return new ErrorEmbeddable(e, initialInput, parent); + } + } + + public getDescription() { + return i18n.translate('xpack.observability.sloEmbeddable.description', { + defaultMessage: 'Get an overview of your SLO health', + }); + } + + public getDisplayName() { + return i18n.translate('xpack.observability.sloEmbeddable.displayName', { + defaultMessage: 'SLO Overview', + }); + } + + public getIconType() { + return 'visGauge'; + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx new file mode 100644 index 0000000000000..5e8947a6c5ba9 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx @@ -0,0 +1,157 @@ +/* + * 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, { useCallback, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiIcon, useEuiBackgroundColor } from '@elastic/eui'; +import { Chart, Metric, MetricTrendShape, Settings } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { ALL_VALUE } from '@kbn/slo-schema'; +import { EuiLoadingChart } from '@elastic/eui'; +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; +import { useKibana } from '../../../utils/kibana_react'; +import { useFetchSloDetails } from '../../../hooks/slo/use_fetch_slo_details'; +import { paths } from '../../../../common/locators/paths'; + +import { EmbeddableSloProps } from './types'; + +export function SloOverview({ sloId, sloInstanceId, lastReloadRequestTime }: EmbeddableSloProps) { + const { + uiSettings, + application: { navigateToUrl }, + http: { basePath }, + } = useKibana().services; + const { isLoading, slo, refetch, isRefetching } = useFetchSloDetails({ + sloId, + instanceId: sloInstanceId, + }); + + useEffect(() => { + refetch(); + }, [lastReloadRequestTime, refetch]); + + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + const isSloNotFound = !isLoading && slo === undefined; + + const getIcon = useCallback( + (type: string) => + ({ width = 20, height = 20, color }: { width: number; height: number; color: string }) => { + return ; + }, + [] + ); + + const sloSummary = slo?.summary; + const sloStatus = sloSummary?.status; + const healthyColor = useEuiBackgroundColor('success'); + const noDataColor = useEuiBackgroundColor('subdued'); + const degradingColor = useEuiBackgroundColor('warning'); + const violatedColor = useEuiBackgroundColor('danger'); + let color; + switch (sloStatus) { + case 'HEALTHY': + color = healthyColor; + break; + case 'NO_DATA': + color = noDataColor; + break; + case 'DEGRADING': + color = degradingColor; + break; + case 'VIOLATED': + color = violatedColor; + break; + default: + color = noDataColor; + } + + if (isRefetching || isLoading) { + return ( + + + + + + ); + } + + if (isSloNotFound) { + return ( + + + {i18n.translate('xpack.observability.sloEmbeddable.overview.sloNotFoundText', { + defaultMessage: + 'The SLO has been deleted. You can safely delete the widget from the dashboard.', + })} + + + ); + } + const TargetCopy = i18n.translate('xpack.observability.sloEmbeddable.overview.sloTargetLabel', { + defaultMessage: 'Target', + }); + const extraContent = `${TargetCopy} ${numeral(slo?.objective.target).format( + percentFormat + )}`; + // eslint-disable-next-line react/no-danger + const extra = ; + const metricData = + slo !== undefined + ? [ + { + color, + title: slo.name, + subtitle: slo.groupBy !== ALL_VALUE ? `${slo.groupBy}:${slo.instanceId}` : '', + icon: getIcon('visGauge'), + value: + sloStatus === 'NO_DATA' + ? NOT_AVAILABLE_LABEL + : numeral(slo.summary.sliValue).format(percentFormat), + valueFormatter: (value: number) => `${value}%`, + extra, + trend: [], + trendShape: MetricTrendShape.Area, + }, + ] + : []; + return ( + <> + + { + navigateToUrl( + basePath.prepend( + paths.observability.sloDetails( + slo!.id, + slo?.groupBy !== ALL_VALUE && slo?.instanceId ? slo.instanceId : undefined + ) + ) + ); + }} + /> + + + + ); +} + +export const LoadingContainer = euiStyled.div` + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; +`; + +export const LoadingContent = euiStyled.div` + flex: 0 0 auto; + align-self: center; + text-align: center; +`; diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx new file mode 100644 index 0000000000000..468358127bd18 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_selector.tsx @@ -0,0 +1,101 @@ +/* + * 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, useMemo, useState } from 'react'; +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list'; + +interface Props { + initialSlo?: SLOWithSummaryResponse; + onSelected: (slo: SLOWithSummaryResponse | undefined) => void; + hasError?: boolean; +} + +const SLO_REQUIRED = i18n.translate('xpack.observability.sloEmbeddable.config.errors.sloRequired', { + defaultMessage: 'SLO is required.', +}); + +export function SloSelector({ initialSlo, onSelected, hasError }: Props) { + const [options, setOptions] = useState>>([]); + const [selectedOptions, setSelectedOptions] = useState>>(); + const [searchValue, setSearchValue] = useState(''); + const { isInitialLoading, isLoading, sloList } = useFetchSloList({ + kqlQuery: `slo.name: ${searchValue.replaceAll(' ', '*')}*`, + }); + + useEffect(() => { + const isLoadedWithData = !isLoading && sloList!.results !== undefined; + const opts: Array> = isLoadedWithData + ? sloList!.results!.map((slo) => { + const label = + slo.instanceId !== ALL_VALUE + ? `${slo.name} (${slo.groupBy}: ${slo.instanceId})` + : slo.name; + return { + value: `${slo.id}-${slo.instanceId}`, + label, + instanceId: slo.instanceId, + }; + }) + : []; + setOptions(opts); + }, [isLoading, sloList]); + + const onChange = (opts: Array>) => { + setSelectedOptions(opts); + const selectedSlo = + opts.length === 1 + ? sloList!.results?.find((slo) => opts[0].value === `${slo.id}-${slo.instanceId}`) + : undefined; + + onSelected(selectedSlo); + }; + + const onSearchChange = useMemo( + () => + debounce((value: string) => { + setSearchValue(value); + }, 300), + [] + ); + + if (isInitialLoading) { + return null; + } + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts b/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts new file mode 100644 index 0000000000000..ea125ffa8a9d5 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/types.ts @@ -0,0 +1,15 @@ +/* + * 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 { EmbeddableInput } from '@kbn/embeddable-plugin/public'; + +export interface EmbeddableSloProps { + sloId: string | undefined; + sloInstanceId: string | undefined; + lastReloadRequestTime?: number | undefined; +} + +export type SloEmbeddableInput = EmbeddableInput & EmbeddableSloProps; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index c40662219ddb7..d864a09fe6fdc 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -56,6 +56,7 @@ import { ObservabilityAIAssistantPluginSetup, ObservabilityAIAssistantPluginStart, } from '@kbn/observability-ai-assistant-plugin/public'; +import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public'; import { AiopsPluginStart } from '@kbn/aiops-plugin/public/types'; import { RulesLocatorDefinition } from './locators/rules'; import { RuleDetailsLocatorDefinition } from './locators/rule_details'; @@ -111,6 +112,7 @@ export interface ObservabilityPublicPluginsSetup { triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; home?: HomePublicPluginSetup; usageCollection: UsageCollectionSetup; + embeddable: EmbeddableSetup; } export interface ObservabilityPublicPluginsStart { @@ -286,6 +288,14 @@ export class Plugin coreSetup.application.register(app); registerObservabilityRuleTypes(config, this.observabilityRuleTypeRegistry); + const registerSloEmbeddableFactory = async () => { + const { SloOverviewEmbeddableFactoryDefinition } = await import( + './embeddable/slo/overview/slo_embeddable_factory' + ); + const factory = new SloOverviewEmbeddableFactoryDefinition(coreSetup.getStartServices); + pluginsSetup.embeddable.registerEmbeddableFactory(factory.type, factory); + }; + registerSloEmbeddableFactory(); if (pluginsSetup.home) { pluginsSetup.home.featureCatalogue.registerSolution({ diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 2b7a9e0749650..cfb16fea9a839 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -84,6 +84,8 @@ "@kbn/core-capabilities-common", "@kbn/observability-ai-assistant-plugin", "@kbn/osquery-plugin", + "@kbn/content-management-plugin", + "@kbn/embeddable-plugin", "@kbn/aiops-plugin", "@kbn/content-management-plugin", "@kbn/deeplinks-observability",