[SLO] create SLO embeddable widget (#165949)

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

<img width="1189" alt="Screenshot 2023-09-21 at 21 07 23"
src="03539b9d-23a5-45eb-aafb-df42e9421f77">


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>
This commit is contained in:
Panagiota Mitsopoulou 2023-09-28 20:39:37 +02:00 committed by GitHub
parent 5a785e8a41
commit 4c3fe71821
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 604 additions and 1 deletions

View file

@ -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"],

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 { EmbeddableSloProps, SloEmbeddableInput } from './types';
import { ObservabilityPublicPluginsStart } from '../../..';
import { SloConfiguration } from './slo_configuration';
export async function resolveEmbeddableSloUserInput(
coreStart: CoreStart,
pluginStart: ObservabilityPublicPluginsStart,
input?: SloEmbeddableInput
): 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 { SloOverviewEmbeddableFactoryDefinition } from './slo_embeddable_factory';

View file

@ -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<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
hasError={hasError}
onSelected={(slo) => {
if (slo === undefined) {
setHasError(true);
} else {
setHasError(false);
}
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,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<SloEmbeddableInput, EmbeddableOutput> {
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(
<I18nContext>
<KibanaContextProvider services={this.deps}>
<QueryClientProvider client={queryClient}>
<SloOverview
sloId={sloId}
sloInstanceId={sloInstanceId}
lastReloadRequestTime={this.input.lastReloadRequestTime}
/>
</QueryClientProvider>
</KibanaContextProvider>
</I18nContext>,
node
);
}
public reload() {
if (this.node) {
this.render(this.node);
}
}
public destroy() {
super.destroy();
this.subscription.unsubscribe();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}

View file

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

View file

@ -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 <EuiIcon type={type} width={width} height={height} fill={color} />;
},
[]
);
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 (
<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 TargetCopy = i18n.translate('xpack.observability.sloEmbeddable.overview.sloTargetLabel', {
defaultMessage: 'Target',
});
const extraContent = `${TargetCopy} <b>${numeral(slo?.objective.target).format(
percentFormat
)}</b>`;
// eslint-disable-next-line react/no-danger
const extra = <span dangerouslySetInnerHTML={{ __html: extraContent }} />;
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 (
<>
<Chart>
<Settings
onElementClick={() => {
navigateToUrl(
basePath.prepend(
paths.observability.sloDetails(
slo!.id,
slo?.groupBy !== ALL_VALUE && slo?.instanceId ? slo.instanceId : undefined
)
)
);
}}
/>
<Metric id={`${slo?.id}-${slo?.instanceId}`} data={[metricData]} />
</Chart>
</>
);
}
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,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<Array<EuiComboBoxOptionOption<string>>>([]);
const [selectedOptions, setSelectedOptions] = useState<Array<EuiComboBoxOptionOption<string>>>();
const [searchValue, setSearchValue] = useState<string>('');
const { isInitialLoading, isLoading, sloList } = useFetchSloList({
kqlQuery: `slo.name: ${searchValue.replaceAll(' ', '*')}*`,
});
useEffect(() => {
const isLoadedWithData = !isLoading && sloList!.results !== undefined;
const opts: Array<EuiComboBoxOptionOption<string>> = 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<EuiComboBoxOptionOption<string>>) => {
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 (
<EuiFormRow fullWidth isInvalid={hasError} error={hasError ? SLO_REQUIRED : undefined}>
<EuiComboBox
aria-label={i18n.translate(
'xpack.observability.sloEmbeddable.config.sloSelector.ariaLabel',
{
defaultMessage: 'SLO',
}
)}
placeholder={i18n.translate(
'xpack.observability.sloEmbeddable.config.sloSelector.placeholder',
{
defaultMessage: 'Select a SLO',
}
)}
data-test-subj="sloSelector"
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedOptions}
async
isLoading={isLoading}
onChange={onChange}
fullWidth
onSearchChange={onSearchChange}
isInvalid={hasError}
/>
</EuiFormRow>
);
}

View file

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

View file

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

View file

@ -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",