chore(slo): UI and tests improvements (#152959)

This commit is contained in:
Kevin Delemme 2023-03-15 15:02:15 -04:00 committed by GitHub
parent fc9a63118c
commit 57bbdd658d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 370 additions and 278 deletions

View file

@ -5,13 +5,7 @@
* 2.0.
*/
import {
asDecimal,
asInteger,
asPercent,
asPercentWithTwoDecimals,
asDecimalOrInteger,
} from './formatters';
import { asDecimal, asInteger, asPercent, asDecimalOrInteger } from './formatters';
describe('formatters', () => {
describe('asDecimal', () => {
@ -95,33 +89,6 @@ describe('formatters', () => {
});
});
describe('asPercentWithTwoDecimals', () => {
it('formats as integer when number is above 10', () => {
expect(asPercentWithTwoDecimals(3725, 10000, 'n/a')).toEqual('37.25%');
});
it('adds a decimal when value is below 10', () => {
expect(asPercentWithTwoDecimals(0.092, 1)).toEqual('9.20%');
});
it('formats when numerator is 0', () => {
expect(asPercentWithTwoDecimals(0, 1, 'n/a')).toEqual('0%');
});
it('returns fallback when denominator is undefined', () => {
expect(asPercentWithTwoDecimals(3725, undefined, 'n/a')).toEqual('n/a');
});
it('returns fallback when denominator is 0 ', () => {
expect(asPercentWithTwoDecimals(3725, 0, 'n/a')).toEqual('n/a');
});
it('returns fallback when numerator or denominator is NaN', () => {
expect(asPercentWithTwoDecimals(3725, NaN, 'n/a')).toEqual('n/a');
expect(asPercentWithTwoDecimals(NaN, 10000, 'n/a')).toEqual('n/a');
});
});
describe('asDecimalOrInteger', () => {
it('formats as integer when number equals to 0 ', () => {
expect(asDecimalOrInteger(0)).toEqual('0');

View file

@ -47,27 +47,6 @@ export function asPercent(
return numeral(decimal).format('0.0%');
}
export function asPercentWithTwoDecimals(
numerator: Maybe<number>,
denominator: number | undefined,
fallbackResult = NOT_AVAILABLE_LABEL
) {
if (!denominator || !isFiniteNumber(numerator)) {
return fallbackResult;
}
const decimal = numerator / denominator;
// 33.2 => 33.20%
// 3.32 => 3.32%
// 0 => 0%
if (String(Math.abs(decimal)).split('.').at(1)?.length === 2 || decimal === 0 || decimal === 1) {
return numeral(decimal).format('0%');
}
return numeral(decimal).format('0.00%');
}
export type AsPercent = typeof asPercent;
export function asDecimalOrInteger(value: number) {

View file

@ -6,12 +6,14 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat, EuiText, EuiTitle } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { useKibana } from '../../../utils/kibana_react';
import { toDurationLabel } from '../../../utils/slo/labels';
import { ChartData } from '../../../typings/slo';
import { toHighPrecisionPercentage } from '../helpers/number';
import { WideChart } from './wide_chart';
export interface Props {
@ -21,10 +23,13 @@ export interface Props {
}
export function ErrorBudgetChartPanel({ 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';
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder>
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="errorBudgetChartPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
@ -40,7 +45,7 @@ export function ErrorBudgetChartPanel({ data, isLoading, slo }: Props) {
<EuiText color="subdued" size="s">
{i18n.translate('xpack.observability.slo.sloDetails.errorBudgetChartPanel.duration', {
defaultMessage: 'Last {duration}',
values: { duration: slo.timeWindow.duration },
values: { duration: toDurationLabel(slo.timeWindow.duration) },
})}
</EuiText>
</EuiFlexItem>
@ -50,7 +55,7 @@ export function ErrorBudgetChartPanel({ data, isLoading, slo }: Props) {
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={`${toHighPrecisionPercentage(slo.summary.errorBudget.remaining)}%`}
title={numeral(slo.summary.errorBudget.remaining).format(percentFormat)}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.errorBudgetChartPanel.remaining',

View file

@ -6,14 +6,15 @@
*/
import { EuiFlexGroup, EuiPanel } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import moment from 'moment';
import React from 'react';
import { toBudgetingMethodLabel, toIndicatorTypeLabel } from '../../../utils/slo/labels';
import { toDurationLabel } from '../../../utils/slo/labels';
import { useKibana } from '../../../utils/kibana_react';
import { toHighPrecisionPercentage } from '../helpers/number';
import { OverviewItem } from './overview_item';
export interface Props {
@ -23,10 +24,11 @@ export interface Props {
export function Overview({ slo }: Props) {
const { uiSettings } = useKibana().services;
const dateFormat = uiSettings.get('dateFormat');
const percentFormat = uiSettings.get('format:percent:defaultPattern');
const hasNoData = slo.summary.status === 'NO_DATA';
return (
<EuiPanel paddingSize="none" color="transparent">
<EuiPanel paddingSize="none" color="transparent" data-test-subj="overview">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="row" alignItems="flexStart">
<OverviewItem
@ -41,8 +43,8 @@ export function Overview({ slo }: Props) {
{
defaultMessage: '{value} (objective is {objective})',
values: {
value: hasNoData ? '-' : `${toHighPrecisionPercentage(slo.summary.sliValue)}%`,
objective: `${toHighPrecisionPercentage(slo.objective.target)}%`,
value: hasNoData ? '-' : numeral(slo.summary.sliValue).format(percentFormat),
objective: numeral(slo.objective.target).format(percentFormat),
},
}
)}
@ -69,7 +71,7 @@ export function Overview({ slo }: Props) {
defaultMessage: 'Budgeting method',
}
)}
subtitle={toBudgetingMethod(slo.budgetingMethod)}
subtitle={toBudgetingMethodLabel(slo.budgetingMethod)}
/>
</EuiFlexGroup>
@ -109,7 +111,7 @@ function toTimeWindowLabel(timeWindow: SLOWithSummaryResponse['timeWindow']): st
return i18n.translate('xpack.observability.slo.sloDetails.overview.rollingTimeWindow', {
defaultMessage: '{duration} rolling',
values: {
duration: timeWindow.duration,
duration: toDurationLabel(timeWindow.duration),
},
});
}
@ -121,40 +123,3 @@ function toTimeWindowLabel(timeWindow: SLOWithSummaryResponse['timeWindow']): st
},
});
}
function toIndicatorTypeLabel(indicatorType: SLOWithSummaryResponse['indicator']['type']): string {
switch (indicatorType) {
case 'sli.kql.custom':
return i18n.translate('xpack.observability.slo.sloDetails.overview.customKqlIndicator', {
defaultMessage: 'Custom KQL',
});
case 'sli.apm.transactionDuration':
return i18n.translate('xpack.observability.slo.sloDetails.overview.apmLatencyIndicator', {
defaultMessage: 'APM latency',
});
case 'sli.apm.transactionErrorRate':
return i18n.translate(
'xpack.observability.slo.sloDetails.overview.apmAvailabilityIndicator',
{
defaultMessage: 'APM availability',
}
);
default:
assertNever(indicatorType);
}
}
function toBudgetingMethod(budgetingMethod: SLOWithSummaryResponse['budgetingMethod']): string {
if (budgetingMethod === 'occurrences') {
return i18n.translate(
'xpack.observability.slo.sloDetails.overview.occurrencesBudgetingMethod',
{ defaultMessage: 'Occurrences' }
);
}
return i18n.translate('xpack.observability.slo.sloDetails.overview.timeslicesBudgetingMethod', {
defaultMessage: 'Timeslices',
});
}

View file

@ -6,12 +6,14 @@
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiStat, EuiText, EuiTitle } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import React from 'react';
import { useKibana } from '../../../utils/kibana_react';
import { toDurationLabel } from '../../../utils/slo/labels';
import { ChartData } from '../../../typings/slo';
import { toHighPrecisionPercentage } from '../helpers/number';
import { WideChart } from './wide_chart';
export interface Props {
@ -21,11 +23,14 @@ export interface Props {
}
export function SliChartPanel({ 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';
const hasNoData = slo.summary.status === 'NO_DATA';
return (
<EuiPanel paddingSize="m" color="transparent" hasBorder>
<EuiPanel paddingSize="m" color="transparent" hasBorder data-test-subj="sliChartPanel">
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
@ -41,7 +46,7 @@ export function SliChartPanel({ data, isLoading, slo }: Props) {
<EuiText color="subdued" size="s">
{i18n.translate('xpack.observability.slo.sloDetails.sliHistoryChartPanel.duration', {
defaultMessage: 'Last {duration}',
values: { duration: slo.timeWindow.duration },
values: { duration: toDurationLabel(slo.timeWindow.duration) },
})}
</EuiText>
</EuiFlexItem>
@ -51,7 +56,7 @@ export function SliChartPanel({ data, isLoading, slo }: Props) {
<EuiFlexItem grow={false}>
<EuiStat
titleColor={isSloFailed ? 'danger' : 'success'}
title={hasNoData ? '-' : `${toHighPrecisionPercentage(slo.summary.sliValue)}%`}
title={hasNoData ? '-' : numeral(slo.summary.sliValue).format(percentFormat)}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.sliHistoryChartPanel.current',
@ -62,7 +67,7 @@ export function SliChartPanel({ data, isLoading, slo }: Props) {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiStat
title={`${toHighPrecisionPercentage(slo.objective.target)}%`}
title={numeral(slo.objective.target).format(percentFormat)}
titleSize="s"
description={i18n.translate(
'xpack.observability.slo.sloDetails.sliHistoryChartPanel.objective',

View file

@ -17,11 +17,11 @@ import {
} from '@elastic/charts';
import React from 'react';
import { EuiIcon, EuiLoadingChart, useEuiTheme } from '@elastic/eui';
import numeral from '@elastic/numeral';
import moment from 'moment';
import { ChartData } from '../../../typings';
import { useKibana } from '../../../utils/kibana_react';
import { toHighPrecisionPercentage } from '../helpers/number';
type ChartType = 'area' | 'line';
type State = 'success' | 'error';
@ -40,12 +40,13 @@ export function WideChart({ chart, data, id, isLoading, state }: Props) {
const baseTheme = charts.theme.useChartsBaseTheme();
const { euiTheme } = useEuiTheme();
const dateFormat = uiSettings.get('dateFormat');
const percentFormat = uiSettings.get('format:percent:defaultPattern');
const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success;
const ChartComponent = chart === 'area' ? AreaSeries : LineSeries;
if (isLoading) {
return <EuiLoadingChart size="m" mono />;
return <EuiLoadingChart size="m" mono data-test-subj="wideChartLoading" />;
}
return (
@ -67,7 +68,7 @@ export function WideChart({ chart, data, id, isLoading, state }: Props) {
id="left"
ticks={4}
position={Position.Left}
tickFormat={(d) => `${toHighPrecisionPercentage(d)}%`}
tickFormat={(d) => numeral(d).format(percentFormat)}
/>
<ChartComponent
color={color}

View file

@ -1,10 +0,0 @@
/*
* 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 toHighPrecisionPercentage(value: number): number {
return Math.trunc(value * 100000) / 1000;
}

View file

@ -18,7 +18,10 @@ import { buildSlo } from '../../data/slo/slo';
import { paths } from '../../config';
import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_summary';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { historicalSummaryData } from '../../data/slo/historical_summary_data';
import {
HEALTHY_STEP_DOWN_ROLLING_SLO,
historicalSummaryData,
} from '../../data/slo/historical_summary_data';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { buildApmAvailabilityIndicator } from '../../data/slo/indicator';
@ -57,6 +60,7 @@ const mockKibana = () => {
uiSettings: {
get: (settings: string) => {
if (settings === 'dateFormat') return 'YYYY-MM-DD';
if (settings === 'format:percent:defaultPattern') return '0.0%';
return '';
},
},
@ -88,31 +92,78 @@ describe('SLO Details Page', () => {
});
});
describe('when the correct license is found', () => {
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('nonexistent');
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
it('renders the PageNotFound when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('nonexistent');
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the loading spinner when fetching the SLO', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: true, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('sloDetailsLoading')).toBeTruthy();
});
it('renders the SLO details page with loading charts when summary data is loading', async () => {
const slo = buildSlo({ id: HEALTHY_STEP_DOWN_ROLLING_SLO });
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: true,
sloHistoricalSummaryResponse: {},
});
it('renders the loading spinner when fetching the SLO', async () => {
const slo = buildSlo();
render(<SloDetailsPage />);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
expect(screen.queryByTestId('overview')).toBeTruthy();
expect(screen.queryByTestId('sliChartPanel')).toBeTruthy();
expect(screen.queryByTestId('errorBudgetChartPanel')).toBeTruthy();
expect(screen.queryAllByTestId('wideChartLoading').length).toBe(2);
});
it('renders the SLO details page with the overview and chart panels', async () => {
const slo = buildSlo({ id: HEALTHY_STEP_DOWN_ROLLING_SLO });
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
expect(screen.queryByTestId('overview')).toBeTruthy();
expect(screen.queryByTestId('sliChartPanel')).toBeTruthy();
expect(screen.queryByTestId('errorBudgetChartPanel')).toBeTruthy();
expect(screen.queryAllByTestId('wideChartLoading').length).toBe(0);
});
describe('when an APM SLO is loaded', () => {
it("should render a 'Explore in APM' button", async () => {
const slo = buildSlo({ indicator: buildApmAvailabilityIndicator() });
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: true, slo: undefined });
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('sloDetailsLoading')).toBeTruthy();
expect(screen.queryByTestId('sloDetailsExploreInApmButton')).toBeTruthy();
});
});
it('renders the SLO details page', async () => {
describe('when an Custom KQL SLO is loaded', () => {
it("should not render a 'Explore in APM' button", async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
@ -120,33 +171,7 @@ describe('SLO Details Page', () => {
render(<SloDetailsPage />);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
});
describe('when an APM SLO is loaded', () => {
it('should render a Explore in APM button', async () => {
const slo = buildSlo({ indicator: buildApmAvailabilityIndicator() });
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('sloDetailsExploreInApmButton')).toBeTruthy();
});
});
describe('when an Custom KQL SLO is loaded', () => {
it('should not render a Explore in APM button', async () => {
const slo = buildSlo();
useParamsMock.mockReturnValue(slo.id);
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, slo });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />);
expect(screen.queryByTestId('sloDetailsExploreInApmButton')).toBeFalsy();
});
expect(screen.queryByTestId('sloDetailsExploreInApmButton')).toBeFalsy();
});
});
});

View file

@ -7,6 +7,13 @@
import { i18n } from '@kbn/i18n';
import { BudgetingMethod, CreateSLOInput } from '@kbn/slo-schema';
import {
BUDGETING_METHOD_OCCURRENCES,
BUDGETING_METHOD_TIMESLICES,
INDICATOR_APM_AVAILABILITY,
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
} from '../../utils/slo/labels';
export const SLI_OPTIONS: Array<{
value: CreateSLOInput['indicator']['type'];
@ -14,36 +21,26 @@ export const SLI_OPTIONS: Array<{
}> = [
{
value: 'sli.kql.custom',
text: i18n.translate('xpack.observability.slo.sliTypes.kqlCustomIndicator', {
defaultMessage: 'KQL custom',
}),
text: INDICATOR_CUSTOM_KQL,
},
{
value: 'sli.apm.transactionDuration',
text: i18n.translate('xpack.observability.slo.sliTypes.apmLatencyIndicator', {
defaultMessage: 'APM latency',
}),
text: INDICATOR_APM_LATENCY,
},
{
value: 'sli.apm.transactionErrorRate',
text: i18n.translate('xpack.observability.slo.sliTypes.apmAvailabilityIndicator', {
defaultMessage: 'APM availability',
}),
text: INDICATOR_APM_AVAILABILITY,
},
];
export const BUDGETING_METHOD_OPTIONS: Array<{ value: BudgetingMethod; text: string }> = [
{
value: 'occurrences',
text: i18n.translate('xpack.observability.slo.sloEdit.budgetingMethod.occurrences', {
defaultMessage: 'Occurrences',
}),
text: BUDGETING_METHOD_OCCURRENCES,
},
{
value: 'timeslices',
text: i18n.translate('xpack.observability.slo.sloEdit.budgetingMethod.timeslices', {
defaultMessage: 'Timeslices',
}),
text: BUDGETING_METHOD_TIMESLICES,
},
];

View file

@ -16,6 +16,7 @@ import { ActiveAlerts } from '../../../../hooks/slo/use_fetch_active_alerts';
import { SloStatusBadge } from '../../../../components/slo/slo_status_badge';
import { SloIndicatorTypeBadge } from './slo_indicator_type_badge';
import { SloTimeWindowBadge } from './slo_time_window_badge';
import { toAlertsPageQueryFilter } from '../../helpers/alerts_page_query_filter';
export interface Props {
slo: SLOWithSummaryResponse;
@ -31,7 +32,9 @@ export function SloBadges({ slo, activeAlerts }: Props) {
const handleClick = () => {
if (activeAlerts) {
navigateToUrl(
`${basePath.prepend(paths.observability.alerts)}?_a=${toAlertsPageQuery(activeAlerts)}`
`${basePath.prepend(paths.observability.alerts)}?_a=${toAlertsPageQueryFilter(
activeAlerts
)}`
);
}
};
@ -67,12 +70,3 @@ export function SloBadges({ slo, activeAlerts }: Props) {
</EuiFlexGroup>
);
}
function toAlertsPageQuery(activeAlerts: ActiveAlerts): string {
const kuery = activeAlerts.ruleIds
.map((ruleId) => `kibana.alert.rule.uuid:"${activeAlerts.ruleIds[0]}"`)
.join(' or ');
const query = `(kuery:'${kuery}',rangeFrom:now-15m,rangeTo:now,status:all)`;
return query;
}

View file

@ -7,10 +7,10 @@
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { euiLightVars } from '@kbn/ui-theme';
import { toIndicatorTypeLabel } from '../../../../utils/slo/labels';
export interface Props {
slo: SLOWithSummaryResponse;
}
@ -19,27 +19,8 @@ export function SloIndicatorTypeBadge({ slo }: Props) {
return (
<div>
<EuiBadge color={euiLightVars.euiColorDisabled}>
{toIndicatorLabel(slo.indicator.type)}
{toIndicatorTypeLabel(slo.indicator.type)}
</EuiBadge>
</div>
);
}
function toIndicatorLabel(indicatorType: SLOWithSummaryResponse['indicator']['type']) {
switch (indicatorType) {
case 'sli.kql.custom':
return i18n.translate('xpack.observability.slo.slo.indicator.customKql', {
defaultMessage: 'KQL',
});
case 'sli.apm.transactionDuration':
return i18n.translate('xpack.observability.slo.slo.indicator.apmLatency', {
defaultMessage: 'Latency',
});
case 'sli.apm.transactionErrorRate':
return i18n.translate('xpack.observability.slo.slo.indicator.apmAvailability', {
defaultMessage: 'Availability',
});
default:
assertNever(indicatorType);
}
}

View file

@ -12,6 +12,9 @@ import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { toMomentUnitOfTime } from '../../../../utils/slo/duration';
import { toDurationLabel } from '../../../../utils/slo/labels';
export interface Props {
slo: SLOWithSummaryResponse;
}
@ -20,7 +23,6 @@ export function SloTimeWindowBadge({ slo }: Props) {
const duration = Number(slo.timeWindow.duration.slice(0, -1));
const unit = slo.timeWindow.duration.slice(-1);
if ('isRolling' in slo.timeWindow) {
const label = toDurationLabel(duration, unit);
return (
<div>
<EuiBadge
@ -28,7 +30,7 @@ export function SloTimeWindowBadge({ slo }: Props) {
iconType="editorItemAlignRight"
iconSide="left"
>
{label}
{toDurationLabel(slo.timeWindow.duration)}
</EuiBadge>
</div>
);
@ -61,48 +63,3 @@ export function SloTimeWindowBadge({ slo }: Props) {
</div>
);
}
function toDurationLabel(duration: number, durationUnit: string) {
switch (durationUnit) {
case 'd':
return i18n.translate('xpack.observability.slo.slo.timeWindow.days', {
defaultMessage: '{duration} days',
values: { duration },
});
case 'w':
return i18n.translate('xpack.observability.slo.slo.timeWindow.weeks', {
defaultMessage: '{duration} weeks',
values: { duration },
});
case 'M':
return i18n.translate('xpack.observability.slo.slo.timeWindow.months', {
defaultMessage: '{duration} months',
values: { duration },
});
case 'Q':
return i18n.translate('xpack.observability.slo.slo.timeWindow.quarterss', {
defaultMessage: '{duration} quarters',
values: { duration },
});
case 'Y':
return i18n.translate('xpack.observability.slo.slo.timeWindow.years', {
defaultMessage: '{duration} years',
values: { duration },
});
}
}
const toMomentUnitOfTime = (unit: string): moment.unitOfTime.Diff | undefined => {
switch (unit) {
case 'd':
return 'days';
case 'w':
return 'weeks';
case 'M':
return 'months';
case 'Q':
return 'quarters';
case 'Y':
return 'years';
}
};

View file

@ -19,6 +19,11 @@ import {
import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
import { i18n } from '@kbn/i18n';
import React, { useEffect, useState } from 'react';
import {
INDICATOR_APM_AVAILABILITY,
INDICATOR_APM_LATENCY,
INDICATOR_CUSTOM_KQL,
} from '../../../utils/slo/labels';
export interface SloListSearchFilterSortBarProps {
loading: boolean;
@ -57,21 +62,15 @@ const SORT_OPTIONS: Array<Item<SortType>> = [
const INDICATOR_TYPE_OPTIONS: Array<Item<FilterType>> = [
{
label: i18n.translate('xpack.observability.slo.list.indicatorTypeFilter.apmLatency', {
defaultMessage: 'APM latency',
}),
label: INDICATOR_APM_LATENCY,
type: 'sli.apm.transactionDuration',
},
{
label: i18n.translate('xpack.observability.slo.list.indicatorTypeFilter.apmAvailability', {
defaultMessage: 'APM availability',
}),
label: INDICATOR_APM_AVAILABILITY,
type: 'sli.apm.transactionErrorRate',
},
{
label: i18n.translate('xpack.observability.slo.list.indicatorTypeFilter.customKql', {
defaultMessage: 'Custom KQL',
}),
label: INDICATOR_CUSTOM_KQL,
type: 'sli.kql.custom',
},
];

View file

@ -23,10 +23,10 @@ export interface Props {
data: Data[];
chart: ChartType;
state: State;
loading: boolean;
isLoading: boolean;
}
export function SloSparkline({ chart, data, id, loading, state }: Props) {
export function SloSparkline({ chart, data, id, isLoading, state }: Props) {
const charts = useKibana().services.charts;
const theme = charts.theme.useChartsTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
@ -36,7 +36,7 @@ export function SloSparkline({ chart, data, id, loading, state }: Props) {
const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success;
const ChartComponent = chart === 'area' ? AreaSeries : LineSeries;
if (loading) {
if (isLoading) {
return <EuiLoadingChart size="m" mono />;
}

View file

@ -6,12 +6,13 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui';
import numeral from '@elastic/numeral';
import { i18n } from '@kbn/i18n';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { formatHistoricalData } from '../../../utils/slo/chart_data_formatter';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { asPercentWithTwoDecimals } from '../../../../common/utils/formatters';
import { SloSparkline } from './slo_sparkline';
export interface Props {
@ -21,6 +22,8 @@ export interface Props {
}
export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoading }: Props) {
const { uiSettings } = useKibana().services;
const percentFormat = uiSettings.get('format:percent:defaultPattern');
const isSloFailed = slo.summary.status === 'VIOLATED' || slo.summary.status === 'DEGRADING';
const titleColor = isSloFailed ? 'danger' : '';
const errorBudgetBurnDownData = formatHistoricalData(historicalSummary, 'error_budget_remaining');
@ -28,18 +31,18 @@ export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoadi
return (
<EuiFlexGroup direction="row" justifyContent="spaceBetween" gutterSize="xl">
<EuiFlexItem grow={false} style={{ width: 200 }}>
<EuiFlexItem grow={false} style={{ width: 220 }}>
<EuiFlexGroup direction="row" responsive={false} gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiFlexItem grow={false} style={{ width: 140 }}>
<EuiStat
description={i18n.translate('xpack.observability.slo.slo.stats.objective', {
defaultMessage: '{objective} target',
values: { objective: asPercentWithTwoDecimals(slo.objective.target, 1) },
values: { objective: numeral(slo.objective.target).format(percentFormat) },
})}
title={
slo.summary.status === 'NO_DATA'
? NOT_AVAILABLE_LABEL
: asPercentWithTwoDecimals(slo.summary.sliValue, 1)
: numeral(slo.summary.sliValue).format(percentFormat)
}
titleColor={titleColor}
titleSize="m"
@ -52,7 +55,7 @@ export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoadi
id="sli_history"
state={isSloFailed ? 'error' : 'success'}
data={historicalSliData}
loading={historicalSummaryLoading}
isLoading={historicalSummaryLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -65,19 +68,19 @@ export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoadi
description={i18n.translate('xpack.observability.slo.slo.stats.budgetRemaining', {
defaultMessage: 'Budget remaining',
})}
title={asPercentWithTwoDecimals(slo.summary.errorBudget.remaining, 1)}
title={numeral(slo.summary.errorBudget.remaining).format(percentFormat)}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<SloSparkline
chart="area"
id="error_budget_burn_down"
state={isSloFailed ? 'error' : 'success'}
data={errorBudgetBurnDownData}
loading={historicalSummaryLoading}
isLoading={historicalSummaryLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,18 @@
/*
* 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 { toAlertsPageQueryFilter } from './alerts_page_query_filter';
describe('AlertsPageQueryFilter', () => {
it('computes the query filter correctly', async () => {
expect(
toAlertsPageQueryFilter({ count: 2, ruleIds: ['rule-1', 'rule-2'] })
).toMatchInlineSnapshot(
`"(kuery:'kibana.alert.rule.uuid:\\"rule-1\\" or kibana.alert.rule.uuid:\\"rule-2\\"',rangeFrom:now-15m,rangeTo:now,status:all)"`
);
});
});

View file

@ -0,0 +1,17 @@
/*
* 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 { ActiveAlerts } from '../../../hooks/slo/use_fetch_active_alerts';
export function toAlertsPageQueryFilter(activeAlerts: ActiveAlerts): string {
const kuery = activeAlerts.ruleIds
.map((ruleId) => `kibana.alert.rule.uuid:"${ruleId}"`)
.join(' or ');
const query = `(kuery:'${kuery}',rangeFrom:now-15m,rangeTo:now,status:all)`;
return query;
}

View file

@ -77,6 +77,13 @@ const mockKibana = () => {
addError: mockAddError,
},
},
uiSettings: {
get: (settings: string) => {
if (settings === 'dateFormat') return 'YYYY-MM-DD';
if (settings === 'format:percent:defaultPattern') return '0.0%';
return '';
},
},
},
});
};

View file

@ -75,6 +75,9 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
if (setting === 'dateFormat') {
return 'MMM D, YYYY @ HH:mm:ss.SSS';
}
if (setting === 'format:percent:defaultPattern') {
return '0,0.[000]%';
}
},
},
unifiedSearch: {},

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import moment from 'moment';
import { assertNever } from '@kbn/std';
import { Duration, DurationUnit } from '../../typings';
@ -33,3 +34,18 @@ export function toMinutes(duration: Duration) {
assertNever(duration.unit);
}
export function toMomentUnitOfTime(unit: string): moment.unitOfTime.Diff | undefined {
switch (unit) {
case 'd':
return 'days';
case 'w':
return 'weeks';
case 'M':
return 'months';
case 'Q':
return 'quarters';
case 'Y':
return 'years';
}
}

View file

@ -0,0 +1,115 @@
/*
* 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 { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { toDuration } from './duration';
export const INDICATOR_CUSTOM_KQL = i18n.translate('xpack.observability.slo.indicators.customKql', {
defaultMessage: 'Custom KQL',
});
export const INDICATOR_APM_LATENCY = i18n.translate(
'xpack.observability.slo.indicators.apmLatency',
{ defaultMessage: 'APM latency' }
);
export const INDICATOR_APM_AVAILABILITY = i18n.translate(
'xpack.observability.slo.indicators.apmAvailability',
{ defaultMessage: 'APM availability' }
);
export function toIndicatorTypeLabel(
indicatorType: SLOWithSummaryResponse['indicator']['type']
): string {
switch (indicatorType) {
case 'sli.kql.custom':
return INDICATOR_CUSTOM_KQL;
case 'sli.apm.transactionDuration':
return INDICATOR_APM_LATENCY;
case 'sli.apm.transactionErrorRate':
return INDICATOR_APM_AVAILABILITY;
default:
assertNever(indicatorType);
}
}
export const BUDGETING_METHOD_OCCURRENCES = i18n.translate(
'xpack.observability.slo.budgetingMethod.occurrences',
{
defaultMessage: 'Occurrences',
}
);
export const BUDGETING_METHOD_TIMESLICES = i18n.translate(
'xpack.observability.slo.budgetingMethod.timeslices',
{
defaultMessage: 'Timeslices',
}
);
export function toBudgetingMethodLabel(
budgetingMethod: SLOWithSummaryResponse['budgetingMethod']
): string {
if (budgetingMethod === 'occurrences') {
return BUDGETING_METHOD_OCCURRENCES;
}
return BUDGETING_METHOD_TIMESLICES;
}
export function toDurationLabel(durationStr: string): string {
const duration = toDuration(durationStr);
switch (duration.unit) {
case 'm':
return i18n.translate('xpack.observability.slo.duration.minute', {
defaultMessage: '{duration, plural, one {1 minute} other {# minutes}}',
values: {
duration: duration.value,
},
});
case 'h':
return i18n.translate('xpack.observability.slo.duration.hour', {
defaultMessage: '{duration, plural, one {1 hour} other {# hours}}',
values: {
duration: duration.value,
},
});
case 'd':
return i18n.translate('xpack.observability.slo.duration.day', {
defaultMessage: '{duration, plural, one {1 day} other {# days}}',
values: {
duration: duration.value,
},
});
case 'w':
return i18n.translate('xpack.observability.slo.duration.week', {
defaultMessage: '{duration, plural, one {1 week} other {# weeks}}',
values: {
duration: duration.value,
},
});
case 'M':
return i18n.translate('xpack.observability.slo.duration.month', {
defaultMessage: '{duration, plural, one {1 month} other {# months}}',
values: {
duration: duration.value,
},
});
case 'Y':
return i18n.translate('xpack.observability.slo.duration.year', {
defaultMessage: '{duration, plural, one {1 year} other {# years}}',
values: {
duration: duration.value,
},
});
}
}

View file

@ -100,6 +100,50 @@ describe('BurnRateRuleExecutor', () => {
};
});
it('throws when the slo is not found', async () => {
soClientMock.get.mockRejectedValue(new Error('NotFound'));
const executor = getRuleExecutor();
await expect(
executor({
params: someRuleParams({ sloId: 'inexistent', burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
logger: loggerMock,
previousStartedAt: null,
rule: {} as SanitizedRuleConfig,
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
})
).rejects.toThrowError();
});
it('returns early when the slo is disabled', async () => {
const slo = createSLO({ objective: { target: 0.9 }, enabled: false });
soClientMock.get.mockResolvedValue(aStoredSLO(slo));
const executor = getRuleExecutor();
const result = await executor({
params: someRuleParams({ sloId: slo.id, burnRateThreshold: BURN_RATE_THRESHOLD }),
startedAt: new Date(),
services: servicesMock,
executionId: 'irrelevant',
logger: loggerMock,
previousStartedAt: null,
rule: {} as SanitizedRuleConfig,
spaceId: 'irrelevant',
state: {},
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
});
expect(esClientMock.search).not.toHaveBeenCalled();
expect(alertWithLifecycleMock).not.toHaveBeenCalled();
expect(alertFactoryMock.done).not.toHaveBeenCalled();
expect(result).toEqual({ state: {} });
});
it('does not schedule an alert when both windows burn rates are below the threshold', async () => {
const slo = createSLO({ objective: { target: 0.9 } });
soClientMock.get.mockResolvedValue(aStoredSLO(slo));

View file

@ -61,6 +61,10 @@ export const getRuleExecutor = (): LifecycleRuleExecutor<
const summaryClient = new DefaultSLIClient(esClient.asCurrentUser);
const slo = await sloRepository.findById(params.sloId);
if (!slo.enabled) {
return { state: {} };
}
const longWindowDuration = new Duration(
params.longWindow.value,
toDurationUnit(params.longWindow.unit)