[SLO] error budget burn down chart embeddable (#176798)

Fixes https://github.com/elastic/kibana/issues/167572

- A new `SLO Error budget burn down` embeddable is added to the
Dashboard app
- A new `Attach to Dashboard` action is added to the Error budget burn
down chart in the SLO details page
- The selected SLO name is clickable and opens the SLO details page in a
Flyout
- The `Attach to Dashboard` action is hidden while on Dashboard app


9a1d257a-0122-415f-ac5c-94c4aa0dff91

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Panagiota Mitsopoulou 2024-02-22 11:49:02 +01:00 committed by GitHub
parent 40d57754c8
commit 8f11edc616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 923 additions and 114 deletions

View file

@ -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<EmbeddableSloProps> {
const { overlays } = coreStart;
const queryClient = new QueryClient();
return new Promise(async (resolve, reject) => {
try {
const modalSession = overlays.openModal(
toMountPoint(
<KibanaContextProvider
services={{
...coreStart,
...pluginStart,
}}
>
<QueryClientProvider client={queryClient}>
<SloConfiguration
onCreate={(update: EmbeddableSloProps) => {
modalSession.close();
resolve(update);
}}
onCancel={() => {
modalSession.close();
reject();
}}
/>
</QueryClientProvider>
</KibanaContextProvider>,
{ i18n: coreStart.i18n, theme: coreStart.theme }
)
);
} catch (error) {
reject(error);
}
});
}

View file

@ -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';

View file

@ -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<EmbeddableSloProps>();
const onConfirmClick = () =>
onCreate({
sloId: selectedSlo?.sloId,
sloInstanceId: selectedSlo?.sloInstanceId,
});
const [hasError, setHasError] = useState(false);
return (
<EuiModal onClose={onCancel} style={{ minWidth: 550 }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('xpack.observability.sloEmbeddable.config.sloSelector.headerTitle', {
defaultMessage: 'SLO configuration',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup>
<EuiFlexItem grow>
<SloSelector
singleSelection={true}
hasError={hasError}
onSelected={(slo) => {
setHasError(slo === undefined);
if (slo && 'id' in slo) {
setSelectedSlo({ sloId: slo.id, sloInstanceId: slo.instanceId });
}
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel} data-test-subj="sloCancelButton">
<FormattedMessage
id="xpack.observability.sloEmbeddable.config.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
data-test-subj="sloConfirmButton"
isDisabled={!selectedSlo || hasError}
onClick={onConfirmClick}
fill
>
<FormattedMessage
id="xpack.observability.embeddableSlo.config.confirmButtonLabel"
defaultMessage="Confirm configurations"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
}

View file

@ -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<HTMLDivElement>(null);
const [selectedSlo, setSelectedSlo] = useState<SLOWithSummaryResponse | null>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<number | undefined>(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 (
<LoadingContainer>
<LoadingContent>
<EuiLoadingChart />
</LoadingContent>
</LoadingContainer>
);
}
if (isSloNotFound) {
return (
<LoadingContainer>
<LoadingContent>
{i18n.translate('xpack.observability.sloEmbeddable.overview.sloNotFoundText', {
defaultMessage:
'The SLO has been deleted. You can safely delete the widget from the dashboard.',
})}
</LoadingContent>
</LoadingContainer>
);
}
const hasGroupBy = slo.instanceId !== ALL_VALUE;
return (
<div ref={containerRef} style={{ width: '100%', padding: 10 }}>
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
{hasGroupBy ? (
<EuiTitle size="xs">
<h4>{slo.name}</h4>
</EuiTitle>
) : (
<EuiLink
css={{ fontSize: '16px' }}
data-test-subj="o11ySloErrorBudgetLink"
onClick={() => {
setSelectedSlo(slo);
}}
>
<h4>{slo.name}</h4>
</EuiLink>
)}
</EuiFlexItem>
{hasGroupBy && (
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj="o11ySloErrorBudgetLink"
onClick={() => {
setSelectedSlo(slo);
}}
>
{slo.groupBy}: {slo.instanceId}
</EuiLink>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexGroup direction="column" gutterSize="l">
<ErrorBudgetHeader showTitle={false} slo={slo} />
<ErrorBudgetChart
data={errorBudgetBurnDownData}
isLoading={historicalSummaryLoading}
slo={slo!}
/>
</EuiFlexGroup>
<SloOverviewDetails slo={selectedSlo} setSelectedSlo={setSelectedSlo} />
</div>
);
}
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;
`;

View file

@ -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<boolean>;
constructor(
private readonly deps: SloEmbeddableDeps,
initialInput: SloErrorBudgetEmbeddableInput,
parent?: IContainer
) {
super(initialInput, {}, parent);
this.reloadSubject = new Subject<boolean>();
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(
<I18nContext>
<KibanaContextProvider services={this.deps}>
<QueryClientProvider client={queryClient}>
<SloErrorBudget sloId={sloId} sloInstanceId={sloInstanceId} />
</QueryClientProvider>
</KibanaContextProvider>
</I18nContext>,
node
);
}
public reload() {
this.reloadSubject.next(true);
}
public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}

View file

@ -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<Partial<SloErrorBudgetEmbeddableInput>> {
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';
}
}

View file

@ -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<boolean>;
onRenderComplete?: () => void;
}
export type SloErrorBudgetEmbeddableInput = EmbeddableInput & EmbeddableSloProps;

View file

@ -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';

View file

@ -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';

View file

@ -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 = (
<EuiButtonIcon
data-test-subj="o11yErrorBudgetActionsButton"
iconType={'boxesHorizontal'}
onClick={() => setIsActionsPopoverOpen(!isActionsPopoverOpen)}
/>
);
return (
<EuiPopover
isOpen={isActionsPopoverOpen}
button={ContextMenuButton}
closePopover={() => setIsActionsPopoverOpen(false)}
>
<EuiContextMenuPanel>
<EuiContextMenuItem
icon="dashboardApp"
key="attachToDashboard"
onClick={handleAttachToDashboard}
data-test-subj="sloActinsAttachToDashboard"
>
{i18n.translate('xpack.observability.slo.item.actions.attachToDashboard', {
defaultMessage: 'Attach to Dashboard',
})}
</EuiContextMenuItem>
</EuiContextMenuPanel>
</EuiPopover>
);
}

View file

@ -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 (
<>
<EuiFlexGroup direction="row" gutterSize="l" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={numeral(slo.summary.errorBudget.remaining).format(percentFormat)}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.remaining',
{ defaultMessage: 'Remaining' }
)}
reverse
/>
</EuiFlexItem>
{errorBudgetTimeRemainingFormatted ? (
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={errorBudgetTimeRemainingFormatted}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.remaining',
{ defaultMessage: 'Remaining' }
)}
reverse
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiFlexItem>
<WideChart
chart="area"
id={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.chartTitle',
{
defaultMessage: 'Error budget remaining',
}
)}
state={isSloFailed ? 'error' : 'success'}
data={data}
isLoading={isLoading}
/>
</EuiFlexItem>
</>
);
}

View file

@ -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 (
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="errorBudgetChartPanel">
<>
<EuiPanel
paddingSize="m"
color="transparent"
hasBorder
data-test-subj="errorBudgetChartPanel"
onMouseOver={() => {
if (!isMouseOver) {
setIsMouseOver(true);
}
}}
onMouseLeave={() => {
if (isMouseOver) {
setIsMouseOver(false);
}
}}
>
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="xs">
<h2>
{i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.title', {
defaultMessage: 'Error budget burn down',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{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)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<ErrorBudgetHeader
slo={slo}
showTitle={true}
isMouseOver={isMouseOver}
setDashboardAttachmentReady={setDashboardAttachmentReady}
/>
<EuiFlexGroup direction="row" gutterSize="l" alignItems="flexStart" responsive={false}>
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={numeral(slo.summary.errorBudget.remaining).format(percentFormat)}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.remaining',
{ defaultMessage: 'Remaining' }
)}
reverse
/>
</EuiFlexItem>
{errorBudgetTimeRemainingFormatted ? (
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={errorBudgetTimeRemainingFormatted}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.remaining',
{ defaultMessage: 'Remaining' }
)}
reverse
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiFlexItem>
<WideChart
chart="area"
id={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.chartTitle',
{
defaultMessage: 'Error budget remaining',
}
)}
state={isSloFailed ? 'error' : 'success'}
data={data}
isLoading={isLoading}
/>
</EuiFlexItem>
<ErrorBudgetChart slo={slo} data={data} isLoading={isLoading} />
</EuiFlexGroup>
</EuiPanel>
{isDashboardAttachmentReady ? (
<SavedObjectSaveModalDashboard
objectType={i18n.translate(
'xpack.observability.slo.errorBudgetBurnDown.actions.attachToDashboard.objectTypeLabel',
{ defaultMessage: 'SLO Error Budget burn down' }
)}
documentInfo={{
title: i18n.translate(
'xpack.observability.slo.errorBudgetBurnDown.actions.attachToDashboard.attachmentTitle',
{ defaultMessage: 'SLO Error Budget burn down' }
),
}}
canSaveByReference={false}
onClose={() => {
setDashboardAttachmentReady(false);
}}
onSave={handleAttachToDashboardSave}
/>
) : null}
</>
);
}

View file

@ -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(<ErrorBudgetHeader isMouseOver={true} slo={slo} />);
expect(screen.queryByTestId('o11yErrorBudgetActionsButton')).toBeTruthy();
});
it('renders "Error budget burn down" title', () => {
const slo = buildSlo();
render(<ErrorBudgetHeader isMouseOver={true} slo={slo} />);
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(<ErrorBudgetHeader isMouseOver={true} slo={slo} />);
expect(screen.queryByTestId('o11yErrorBudgetActionsButton')).toBeFalsy();
});
it('does not render the "Error budget burn down" title', () => {
const slo = buildSlo();
render(<ErrorBudgetHeader showTitle={false} isMouseOver={true} slo={slo} />);
expect(screen.queryByTestId('errorBudgetPanelTitle')).toBeFalsy();
});
});

View file

@ -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 (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup>
{showTitle && (
<EuiFlexItem>
<EuiTitle size="xs" data-test-subj="errorBudgetPanelTitle">
<h2>
{i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.title',
{
defaultMessage: 'Error budget burn down',
}
)}
</h2>
</EuiTitle>
</EuiFlexItem>
)}
{!isDashboardContext && (
<EuiFlexGroup justifyContent="flexEnd" wrap>
{isMouseOver && (
<EuiFlexItem grow={false}>
<ErrorBudgetActions setDashboardAttachmentReady={setDashboardAttachmentReady} />
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="subdued" size="s">
{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)}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -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() {}

View file

@ -102,6 +102,11 @@ const mockKibana = () => {
return '';
},
},
executionContext: {
get: () => ({
name: 'observability-overview',
}),
},
},
});
};

View file

@ -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');

View file

@ -108,7 +108,7 @@
"@kbn/aiops-utils",
"@kbn/event-annotation-common",
"@kbn/controls-plugin",
"@kbn/core-lifecycle-browser"
"@kbn/core-lifecycle-browser",
],
"exclude": [
"target/**/*"