diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_details.tsx b/x-pack/plugins/observability/public/embeddable/slo/common/slo_overview_details.tsx similarity index 100% rename from x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_details.tsx rename to x-pack/plugins/observability/public/embeddable/slo/common/slo_overview_details.tsx diff --git a/x-pack/plugins/observability/public/embeddable/slo/error_budget/handle_explicit_input.tsx b/x-pack/plugins/observability/public/embeddable/slo/error_budget/handle_explicit_input.tsx new file mode 100644 index 000000000000..52ddee5ac64f --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/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 { SloErrorBudgetEmbeddableInput, EmbeddableSloProps } from './types'; + +import { ObservabilityPublicPluginsStart } from '../../..'; +import { SloConfiguration } from './slo_configuration'; +export async function resolveEmbeddableSloUserInput( + coreStart: CoreStart, + pluginStart: ObservabilityPublicPluginsStart, + input?: SloErrorBudgetEmbeddableInput +): 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/error_budget/index.ts b/x-pack/plugins/observability/public/embeddable/slo/error_budget/index.ts new file mode 100644 index 000000000000..edd11f1ce4f5 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/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 { SloErrorBudgetEmbeddableFactoryDefinition } from './slo_error_budget_embeddable_factory'; diff --git a/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_configuration.tsx b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_configuration.tsx new file mode 100644 index 000000000000..40347d543ece --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_configuration.tsx @@ -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 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 '../alerts/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', + })} + + + + + + { + setHasError(slo === undefined); + if (slo && 'id' in slo) { + setSelectedSlo({ sloId: slo.id, sloInstanceId: slo.instanceId }); + } + }} + /> + + + + + + + + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_burn_down.tsx b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_burn_down.tsx new file mode 100644 index 000000000000..f26fc8f34c5f --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_burn_down.tsx @@ -0,0 +1,168 @@ +/* + * 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, useState, useRef } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingChart, EuiLink, EuiTitle } from '@elastic/eui'; + +import { euiStyled } from '@kbn/kibana-react-plugin/common'; +import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter'; +import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary'; +import { useFetchSloDetails } from '../../../hooks/slo/use_fetch_slo_details'; + +import { ErrorBudgetChart } from '../../../pages/slo_details/components/error_budget_chart'; +import { EmbeddableSloProps } from './types'; +import { SloOverviewDetails } from '../common/slo_overview_details'; // TODO change to slo_details +import { ErrorBudgetHeader } from '../../../pages/slo_details/components/error_budget_header'; + +export function SloErrorBudget({ + sloId, + sloInstanceId, + onRenderComplete, + reloadSubject, +}: EmbeddableSloProps) { + const containerRef = useRef(null); + const [selectedSlo, setSelectedSlo] = useState(null); + const [lastRefreshTime, setLastRefreshTime] = useState(undefined); + + useEffect(() => { + reloadSubject?.subscribe(() => { + setLastRefreshTime(Date.now()); + }); + return () => { + reloadSubject?.unsubscribe(); + }; + }, [reloadSubject]); + + const { isLoading: historicalSummaryLoading, data: historicalSummaries = [] } = + useFetchHistoricalSummary({ + list: [{ sloId: sloId!, instanceId: sloInstanceId ?? ALL_VALUE }], + shouldRefetch: false, + }); + + const sloHistoricalSummary = historicalSummaries.find( + (historicalSummary) => + historicalSummary.sloId === sloId && + historicalSummary.instanceId === (sloInstanceId ?? ALL_VALUE) + ); + + const errorBudgetBurnDownData = formatHistoricalData( + sloHistoricalSummary?.data, + 'error_budget_remaining' + ); + + const { + isLoading, + data: slo, + refetch, + isRefetching, + } = useFetchSloDetails({ + sloId, + instanceId: sloInstanceId, + }); + + useEffect(() => { + refetch(); + }, [lastRefreshTime, refetch]); + useEffect(() => { + if (!onRenderComplete) return; + + if (!isLoading) { + onRenderComplete(); + } + }, [isLoading, onRenderComplete]); + + const isSloNotFound = !isLoading && slo === undefined; + + if (isRefetching || isLoading || !slo) { + 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 hasGroupBy = slo.instanceId !== ALL_VALUE; + return ( +
+ + + {hasGroupBy ? ( + +

{slo.name}

+
+ ) : ( + { + setSelectedSlo(slo); + }} + > +

{slo.name}

+
+ )} +
+ + {hasGroupBy && ( + + { + setSelectedSlo(slo); + }} + > + {slo.groupBy}: {slo.instanceId} + + + )} +
+ + + + + + + +
+ ); +} + +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/error_budget/slo_error_budget_embeddable.tsx b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_embeddable.tsx new file mode 100644 index 000000000000..cf5b824289cb --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_embeddable.tsx @@ -0,0 +1,102 @@ +/* + * 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 { 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, + NotificationsStart, +} from '@kbn/core/public'; +import { Subject } from 'rxjs'; +import type { SloErrorBudgetEmbeddableInput } from './types'; +import { SloErrorBudget } from './slo_error_budget_burn_down'; +export const SLO_ERROR_BUDGET_EMBEDDABLE = 'SLO_ERROR_BUDGET_EMBEDDABLE'; + +interface SloEmbeddableDeps { + uiSettings: IUiSettingsClient; + http: CoreStart['http']; + i18n: CoreStart['i18n']; + application: ApplicationStart; + notifications: NotificationsStart; +} + +export class SLOErrorBudgetEmbeddable extends AbstractEmbeddable< + SloErrorBudgetEmbeddableInput, + EmbeddableOutput +> { + public readonly type = SLO_ERROR_BUDGET_EMBEDDABLE; + private node?: HTMLElement; + private reloadSubject: Subject; + + constructor( + private readonly deps: SloEmbeddableDeps, + initialInput: SloErrorBudgetEmbeddableInput, + parent?: IContainer + ) { + super(initialInput, {}, parent); + this.reloadSubject = new Subject(); + + this.setTitle( + this.input.title || + i18n.translate('xpack.observability.sloErrorBudgetEmbeddable.displayTitle', { + defaultMessage: 'SLO Error Budget burn down', + }) + ); + } + + setTitle(title: string) { + this.updateInput({ title }); + } + + public onRenderComplete() { + this.renderComplete.dispatchComplete(); + } + + public render(node: HTMLElement) { + super.render(node); + this.node = node; + // required for the export feature to work + this.node.setAttribute('data-shared-item', ''); + + const { sloId, sloInstanceId } = this.getInput(); + const queryClient = new QueryClient(); + + const I18nContext = this.deps.i18n.Context; + ReactDOM.render( + + + + + + + , + node + ); + } + + public reload() { + this.reloadSubject.next(true); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_embeddable_factory.ts b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_embeddable_factory.ts new file mode 100644 index 000000000000..010a4118c178 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/slo_error_budget_embeddable_factory.ts @@ -0,0 +1,76 @@ +/* + * 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 { + EmbeddableFactory, + EmbeddableFactoryDefinition, + ErrorEmbeddable, + IContainer, +} from '@kbn/embeddable-plugin/public'; +import { COMMON_SLO_GROUPING } from '../overview/slo_embeddable_factory'; +import { + SLO_ERROR_BUDGET_EMBEDDABLE, + SLOErrorBudgetEmbeddable, +} from './slo_error_budget_embeddable'; +import { ObservabilityPublicPluginsStart, ObservabilityPublicStart } from '../../..'; +import { SloErrorBudgetEmbeddableInput } from './types'; + +export type SloErrorBudgetEmbeddableFactory = EmbeddableFactory; +export class SloErrorBudgetEmbeddableFactoryDefinition implements EmbeddableFactoryDefinition { + public readonly type = SLO_ERROR_BUDGET_EMBEDDABLE; + + public readonly grouping = COMMON_SLO_GROUPING; + + 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: SloErrorBudgetEmbeddableInput, parent?: IContainer) { + try { + const [coreStart, pluginsStart] = await this.getStartServices(); + const deps = { ...coreStart, ...pluginsStart }; + return new SLOErrorBudgetEmbeddable(deps, initialInput, parent); + } catch (e) { + return new ErrorEmbeddable(e, initialInput, parent); + } + } + + public getDescription() { + return i18n.translate('xpack.observability.sloErrorBudgetEmbeddable.description', { + defaultMessage: 'Get an error budget burn down chart of your SLOs', + }); + } + + public getDisplayName() { + return i18n.translate('xpack.observability.sloErrorBudgetEmbeddable.displayName', { + defaultMessage: 'SLO Error budget burn down', + }); + } + + public getIconType() { + return 'visLine'; + } +} diff --git a/x-pack/plugins/observability/public/embeddable/slo/error_budget/types.ts b/x-pack/plugins/observability/public/embeddable/slo/error_budget/types.ts new file mode 100644 index 000000000000..66e862e64862 --- /dev/null +++ b/x-pack/plugins/observability/public/embeddable/slo/error_budget/types.ts @@ -0,0 +1,16 @@ +/* + * 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'; +import { Subject } from 'rxjs'; + +export interface EmbeddableSloProps { + sloId: string | undefined; + sloInstanceId: string | undefined; + reloadSubject?: Subject; + onRenderComplete?: () => void; +} +export type SloErrorBudgetEmbeddableInput = EmbeddableInput & EmbeddableSloProps; 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 index c3d2c7fa29e6..e0b4e761ea67 100644 --- a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { EuiLoadingChart } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; import { ALL_VALUE, SLOWithSummaryResponse } from '@kbn/slo-schema'; -import { SloOverviewDetails } from './slo_overview_details'; +import { SloOverviewDetails } from '../common/slo_overview_details'; import { SloCardBadgesPortal } from '../../../pages/slos/components/card_view/badges_portal'; import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter'; import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary'; diff --git a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_grid.tsx b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_grid.tsx index 04229eaa0f75..875c1f11ef0a 100644 --- a/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_grid.tsx +++ b/x-pack/plugins/observability/public/embeddable/slo/overview/slo_overview_grid.tsx @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import { MetricDatum } from '@elastic/charts/dist/chart_types/metric/specs'; -import { SloOverviewDetails } from './slo_overview_details'; +import { SloOverviewDetails } from '../common/slo_overview_details'; import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list'; import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter'; import { useFetchRulesForSlo } from '../../../hooks/slo/use_fetch_rules_for_slo'; diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_actions.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_actions.tsx new file mode 100644 index 000000000000..614aaf3456de --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_actions.tsx @@ -0,0 +1,52 @@ +/* + * 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 React, { useState } from 'react'; +import { EuiPopover, EuiContextMenuItem, EuiContextMenuPanel, EuiButtonIcon } from '@elastic/eui'; + +interface Props { + setDashboardAttachmentReady?: (value: boolean) => void; +} + +export function ErrorBudgetActions({ setDashboardAttachmentReady }: Props) { + const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + + const handleAttachToDashboard = () => { + setIsActionsPopoverOpen(false); + if (setDashboardAttachmentReady) { + setDashboardAttachmentReady(true); + } + }; + const ContextMenuButton = ( + setIsActionsPopoverOpen(!isActionsPopoverOpen)} + /> + ); + return ( + setIsActionsPopoverOpen(false)} + > + + + {i18n.translate('xpack.observability.slo.item.actions.attachToDashboard', { + defaultMessage: 'Attach to Dashboard', + })} + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart.tsx new file mode 100644 index 000000000000..857c37d050e1 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart.tsx @@ -0,0 +1,104 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { useKibana } from '../../../utils/kibana_react'; +import { toDuration, toMinutes } from '../../../utils/slo/duration'; +import { ChartData } from '../../../typings/slo'; +import { WideChart } from './wide_chart'; + +function formatTime(minutes: number) { + if (minutes > 59) { + const mins = minutes % 60; + const hours = (minutes - mins) / 60; + return i18n.translate( + 'xpack.observability.slo.sloDetails.errorBudgetChartPanel.minuteHoursLabel', + { + defaultMessage: '{hours}h {mins}m', + values: { hours: Math.trunc(hours), mins: Math.trunc(mins) }, + } + ); + } + return i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.minuteLabel', { + defaultMessage: '{minutes}m', + values: { minutes }, + }); +} +export interface Props { + data: ChartData[]; + isLoading: boolean; + slo: SLOWithSummaryResponse; +} + +export function ErrorBudgetChart({ data, isLoading, slo }: Props) { + const { uiSettings } = useKibana().services; + const percentFormat = uiSettings.get('format:percent:defaultPattern'); + const isSloFailed = slo.summary.status === 'DEGRADING' || slo.summary.status === 'VIOLATED'; + let errorBudgetTimeRemainingFormatted; + if (slo.budgetingMethod === 'timeslices' && slo.timeWindow.type === 'calendarAligned') { + const totalSlices = + toMinutes(toDuration(slo.timeWindow.duration)) / + toMinutes(toDuration(slo.objective.timesliceWindow!)); + const errorBudgetRemainingInMinute = + slo.summary.errorBudget.remaining * (slo.summary.errorBudget.initial * totalSlices); + + errorBudgetTimeRemainingFormatted = formatTime( + errorBudgetRemainingInMinute >= 0 ? errorBudgetRemainingInMinute : 0 + ); + } + return ( + <> + + + + + {errorBudgetTimeRemainingFormatted ? ( + + + + ) : null} + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart_panel.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart_panel.tsx index b47cf7bf3f89..60c8ca627f98 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart_panel.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_chart_panel.tsx @@ -5,132 +5,106 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat, EuiText, EuiTitle } from '@elastic/eui'; -import numeral from '@elastic/numeral'; +import { EuiFlexGroup, EuiPanel } from '@elastic/eui'; +import { + LazySavedObjectSaveModalDashboard, + withSuspense, +} from '@kbn/presentation-util-plugin/public'; import { i18n } from '@kbn/i18n'; -import { rollingTimeWindowTypeSchema, SLOWithSummaryResponse } from '@kbn/slo-schema'; -import React from 'react'; -import { toDuration, toMinutes } from '../../../utils/slo/duration'; +import { SLOWithSummaryResponse } from '@kbn/slo-schema'; +import React, { useState, useCallback } from 'react'; +import { SaveModalDashboardProps } from '@kbn/presentation-util-plugin/public'; import { ChartData } from '../../../typings/slo'; import { useKibana } from '../../../utils/kibana_react'; -import { toDurationAdverbLabel, toDurationLabel } from '../../../utils/slo/labels'; -import { WideChart } from './wide_chart'; - +import { ErrorBudgetChart } from './error_budget_chart'; +import { ErrorBudgetHeader } from './error_budget_header'; +import { SLO_ERROR_BUDGET_EMBEDDABLE } from '../../../embeddable/slo/error_budget/slo_error_budget_embeddable'; +const SavedObjectSaveModalDashboard = withSuspense(LazySavedObjectSaveModalDashboard); export interface Props { data: ChartData[]; isLoading: boolean; slo: SLOWithSummaryResponse; } -function formatTime(minutes: number) { - if (minutes > 59) { - const mins = minutes % 60; - const hours = (minutes - mins) / 60; - return i18n.translate( - 'xpack.observability.slo.sloDetails.errorBudgetChartPanel.minuteHoursLabel', - { - defaultMessage: '{hours}h {mins}m', - values: { hours: Math.trunc(hours), mins: Math.trunc(mins) }, - } - ); - } - return i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.minuteLabel', { - defaultMessage: '{minutes}m', - values: { minutes }, - }); -} - export function ErrorBudgetChartPanel({ data, isLoading, slo }: Props) { - const { uiSettings } = useKibana().services; - const percentFormat = uiSettings.get('format:percent:defaultPattern'); + const [isMouseOver, setIsMouseOver] = useState(false); - const isSloFailed = slo.summary.status === 'DEGRADING' || slo.summary.status === 'VIOLATED'; + const [isDashboardAttachmentReady, setDashboardAttachmentReady] = useState(false); + const { embeddable } = useKibana().services; - let errorBudgetTimeRemainingFormatted; - if (slo.budgetingMethod === 'timeslices' && slo.timeWindow.type === 'calendarAligned') { - const totalSlices = - toMinutes(toDuration(slo.timeWindow.duration)) / - toMinutes(toDuration(slo.objective.timesliceWindow!)); - const errorBudgetRemainingInMinute = - slo.summary.errorBudget.remaining * (slo.summary.errorBudget.initial * totalSlices); + const handleAttachToDashboardSave: SaveModalDashboardProps['onSave'] = useCallback( + ({ dashboardId, newTitle, newDescription }) => { + const stateTransfer = embeddable!.getStateTransfer(); + const embeddableInput = { + title: newTitle, + description: newDescription, + sloId: slo.id, + sloInstanceId: slo.instanceId, + }; - errorBudgetTimeRemainingFormatted = formatTime( - errorBudgetRemainingInMinute >= 0 ? errorBudgetRemainingInMinute : 0 - ); - } + const state = { + input: embeddableInput, + type: SLO_ERROR_BUDGET_EMBEDDABLE, + }; + + const path = dashboardId === 'new' ? '#/create' : `#/view/${dashboardId}`; + + stateTransfer.navigateToWithEmbeddablePackage('dashboards', { + state, + path, + }); + }, + [embeddable, slo.id, slo.instanceId] + ); return ( - - - - - -

- {i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.title', { - defaultMessage: 'Error budget burn down', - })} -

-
-
- - - {rollingTimeWindowTypeSchema.is(slo.timeWindow.type) - ? i18n.translate( - 'xpack.observability.slo.sloDetails.errorBudgetChartPanel.duration', - { - defaultMessage: 'Last {duration}', - values: { duration: toDurationLabel(slo.timeWindow.duration) }, - } - ) - : toDurationAdverbLabel(slo.timeWindow.duration)} - - -
- - - - - - {errorBudgetTimeRemainingFormatted ? ( - - - - ) : null} - - - - + { + if (!isMouseOver) { + setIsMouseOver(true); + } + }} + onMouseLeave={() => { + if (isMouseOver) { + setIsMouseOver(false); + } + }} + > + + - -
-
+ + + + + {isDashboardAttachmentReady ? ( + { + setDashboardAttachmentReady(false); + }} + onSave={handleAttachToDashboardSave} + /> + ) : null} + ); } diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.test.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.test.tsx new file mode 100644 index 000000000000..65a403331497 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { screen } from '@testing-library/react'; +import { useKibana } from '../../../utils/kibana_react'; +import { render } from '../../../utils/test_helper'; +import { buildSlo } from '../../../data/slo/slo'; +import { ErrorBudgetHeader } from './error_budget_header'; + +jest.mock('../../../utils/kibana_react'); +const useKibanaMock = useKibana as jest.Mock; + +describe('In Observability Context', () => { + beforeEach(() => { + jest.clearAllMocks(); + useKibanaMock.mockReturnValue({ + services: { + executionContext: { + get: () => ({ + name: 'observability-overview', + }), + }, + }, + }); + }); + it('renders Error Budget Actions button on mouse over', async () => { + const slo = buildSlo(); + + render(); + expect(screen.queryByTestId('o11yErrorBudgetActionsButton')).toBeTruthy(); + }); + + it('renders "Error budget burn down" title', () => { + const slo = buildSlo(); + render(); + expect(screen.queryByTestId('errorBudgetPanelTitle')).toBeTruthy(); + }); +}); + +describe('In Dashboard Context', () => { + beforeEach(() => { + jest.clearAllMocks(); + + useKibanaMock.mockReturnValue({ + services: { + executionContext: { + get: () => ({ + name: 'dashboards', + }), + }, + }, + }); + }); + it('does not render Error budget Actions button on mouse over', async () => { + const slo = buildSlo(); + render(); + expect(screen.queryByTestId('o11yErrorBudgetActionsButton')).toBeFalsy(); + }); + + it('does not render the "Error budget burn down" title', () => { + const slo = buildSlo(); + render(); + expect(screen.queryByTestId('errorBudgetPanelTitle')).toBeFalsy(); + }); +}); diff --git a/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.tsx b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.tsx new file mode 100644 index 000000000000..9087d43dc002 --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/components/error_budget_header.tsx @@ -0,0 +1,75 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { rollingTimeWindowTypeSchema, SLOWithSummaryResponse } from '@kbn/slo-schema'; +import { toDurationAdverbLabel, toDurationLabel } from '../../../utils/slo/labels'; +import { useKibana } from '../../../utils/kibana_react'; + +import { ErrorBudgetActions } from './error_budget_actions'; + +interface Props { + slo: SLOWithSummaryResponse; + showTitle?: boolean; + isMouseOver?: boolean; + setDashboardAttachmentReady?: (value: boolean) => void; +} + +export function ErrorBudgetHeader({ + slo, + showTitle = true, + isMouseOver, + setDashboardAttachmentReady, +}: Props) { + const { executionContext } = useKibana().services; + const executionContextName = executionContext.get().name; + const isDashboardContext = executionContextName === 'dashboards'; + + return ( + + + + {showTitle && ( + + +

+ {i18n.translate( + 'xpack.observability.slo.sloDetails.errorBudgetChartPanel.title', + { + defaultMessage: 'Error budget burn down', + } + )} +

+
+
+ )} + {!isDashboardContext && ( + + {isMouseOver && ( + + + + )} + + )} +
+
+ + + {rollingTimeWindowTypeSchema.is(slo.timeWindow.type) + ? i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.duration', { + defaultMessage: 'Last {duration}', + values: { duration: toDurationLabel(slo.timeWindow.duration) }, + }) + : toDurationAdverbLabel(slo.timeWindow.duration)} + + +
+ ); +} diff --git a/x-pack/plugins/observability/public/pages/slo_details/hooks/use_error_budget_actions.ts b/x-pack/plugins/observability/public/pages/slo_details/hooks/use_error_budget_actions.ts new file mode 100644 index 000000000000..f0d8933615cf --- /dev/null +++ b/x-pack/plugins/observability/public/pages/slo_details/hooks/use_error_budget_actions.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 function useErrorBudgetActions() {} diff --git a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx index 9d0837bd6fc6..47e1594448b3 100644 --- a/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/slo_details/slo_details.test.tsx @@ -102,6 +102,11 @@ const mockKibana = () => { return ''; }, }, + executionContext: { + get: () => ({ + name: 'observability-overview', + }), + }, }, }); }; diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index d3684f5c90e8..686ac66ea51c 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -352,6 +352,15 @@ export class Plugin }; registerSloAlertsEmbeddableFactory(); + const registerSloErrorBudgetEmbeddableFactory = async () => { + const { SloErrorBudgetEmbeddableFactoryDefinition } = await import( + './embeddable/slo/error_budget/slo_error_budget_embeddable_factory' + ); + const factory = new SloErrorBudgetEmbeddableFactoryDefinition(coreSetup.getStartServices); + pluginsSetup.embeddable.registerEmbeddableFactory(factory.type, factory); + }; + registerSloErrorBudgetEmbeddableFactory(); + const registerAsyncSloAlertsUiActions = async () => { if (pluginsSetup.uiActions) { const { registerSloAlertsUiActions } = await import('./ui_actions'); diff --git a/x-pack/plugins/observability/tsconfig.json b/x-pack/plugins/observability/tsconfig.json index 7030b487be7e..4aeef8105d1b 100644 --- a/x-pack/plugins/observability/tsconfig.json +++ b/x-pack/plugins/observability/tsconfig.json @@ -108,7 +108,7 @@ "@kbn/aiops-utils", "@kbn/event-annotation-common", "@kbn/controls-plugin", - "@kbn/core-lifecycle-browser" + "@kbn/core-lifecycle-browser", ], "exclude": [ "target/**/*"