feat(slo): dashboard with sparkline and badges (#149445)

This commit is contained in:
Kevin Delemme 2023-01-26 11:15:50 -05:00 committed by GitHub
parent da83d96ff6
commit 088a6bb5af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 2630 additions and 310 deletions

View file

@ -131,6 +131,7 @@ type FindSLOResponse = t.OutputOf<typeof findSLOResponseSchema>;
type FetchHistoricalSummaryParams = t.TypeOf<typeof fetchHistoricalSummaryParamsSchema.props.body>;
type FetchHistoricalSummaryResponse = t.OutputOf<typeof fetchHistoricalSummaryResponseSchema>;
type HistoricalSummaryResponse = t.OutputOf<typeof historicalSummarySchema>;
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
@ -158,6 +159,7 @@ export type {
GetSLOResponse,
FetchHistoricalSummaryParams,
FetchHistoricalSummaryResponse,
HistoricalSummaryResponse,
SLOResponse,
SLOWithSummaryResponse,
UpdateSLOInput,

View file

@ -10,7 +10,7 @@ import userEvent from '@testing-library/user-event';
import { wait } from '@testing-library/user-event/dist/utils';
import React from 'react';
import { emptySloList } from '../../../data/slo';
import { emptySloList } from '../../../data/slo/slo';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
import { render } from '../../../utils/test_helper';
import { SloSelector } from './slo_selector';

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { cloneDeep } from 'lodash';
import { v1 as uuidv1 } from 'uuid';
import { FindSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
export const emptySloList: FindSLOResponse = {
@ -17,10 +19,10 @@ export const emptySloList: FindSLOResponse = {
const now = '2022-12-29T10:11:12.000Z';
const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
name: 'irrelevant',
description: 'irrelevant',
name: 'super important level service',
description: 'some description useful',
indicator: {
type: 'sli.kql.custom' as const,
type: 'sli.kql.custom',
params: {
index: 'some-index',
filter: 'baz: foo and bar > 2',
@ -127,6 +129,12 @@ export const anSLO: SLOWithSummaryResponse = {
export const aForecastedSLO: SLOWithSummaryResponse = {
...baseSlo,
timeWindow: {
duration: '1M',
calendar: {
startTime: '2022-01-01T00:00:00.000Z',
},
},
id: '2f17deb0-725a-11ed-ab7c-4bb641cfc57e',
summary: {
status: 'HEALTHY',
@ -139,3 +147,39 @@ export const aForecastedSLO: SLOWithSummaryResponse = {
},
},
};
export function createSLO(params: Partial<SLOWithSummaryResponse> = {}): SLOWithSummaryResponse {
return cloneDeep({ ...baseSlo, id: uuidv1(), ...params });
}
export const anApmAvailabilityIndicator: SLOWithSummaryResponse['indicator'] = {
type: 'sli.apm.transactionErrorRate',
params: {
environment: 'development',
service: 'o11y-app',
transactionType: 'request',
transactionName: 'GET /flaky',
goodStatusCodes: ['2xx', '3xx', '4xx'],
},
};
export const anApmLatencyIndicator: SLOWithSummaryResponse['indicator'] = {
type: 'sli.apm.transactionDuration',
params: {
environment: 'development',
service: 'o11y-app',
transactionType: 'request',
transactionName: 'GET /slow',
'threshold.us': 5000000,
},
};
export const aCustomKqlIndicator: SLOWithSummaryResponse['indicator'] = {
type: 'sli.kql.custom',
params: {
index: 'some_logs*',
good: 'latency < 300',
total: 'latency > 0',
filter: 'labels.eventId: event-0',
},
};

View file

@ -0,0 +1,25 @@
/*
* 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 { HistoricalSummaryResponse } from '@kbn/slo-schema';
import {
HEALTHY_ROLLING_SLO,
historicalSummaryData,
} from '../../../data/slo/historical_summary_data';
import { UseFetchHistoricalSummaryResponse, Params } from '../use_fetch_historical_summary';
export const useFetchHistoricalSummary = ({
sloIds = [],
}: Params): UseFetchHistoricalSummaryResponse => {
const data: Record<string, HistoricalSummaryResponse[]> = {};
sloIds.forEach((sloId) => (data[sloId] = historicalSummaryData[HEALTHY_ROLLING_SLO]));
return {
loading: false,
error: false,
data,
};
};

View file

@ -5,10 +5,10 @@
* 2.0.
*/
import { sloList } from '../../../data/slo';
import { sloList } from '../../../data/slo/slo';
import { UseFetchSloListResponse } from '../use_fetch_slo_list';
export const useFetchSloList = (name?: string): UseFetchSloListResponse => {
export const useFetchSloList = (): UseFetchSloListResponse => {
return {
loading: false,
error: false,

View file

@ -0,0 +1,71 @@
/*
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { HttpSetup } from '@kbn/core/public';
import { FetchHistoricalSummaryResponse } from '@kbn/slo-schema';
import { useDataFetcher } from '../use_data_fetcher';
const EMPTY_RESPONSE: FetchHistoricalSummaryResponse = {};
export interface UseFetchHistoricalSummaryResponse {
data: FetchHistoricalSummaryResponse;
loading: boolean;
error: boolean;
}
export interface Params {
sloIds: string[];
}
export function useFetchHistoricalSummary({
sloIds = [],
}: Params): UseFetchHistoricalSummaryResponse {
const [historicalSummary, setHistoricalSummary] = useState(EMPTY_RESPONSE);
const params: Params = useMemo(() => ({ sloIds }), [sloIds]);
const shouldExecuteApiCall = useCallback(
(apiCallParams: Params) => apiCallParams.sloIds.length > 0,
[]
);
const { data, loading, error } = useDataFetcher<Params, FetchHistoricalSummaryResponse>({
paramsForApiCall: params,
initialDataState: historicalSummary,
executeApiCall: fetchHistoricalSummary,
shouldExecuteApiCall,
});
useEffect(() => {
setHistoricalSummary(data);
}, [data]);
return { data: historicalSummary, loading, error };
}
const fetchHistoricalSummary = async (
params: Params,
abortController: AbortController,
http: HttpSetup
): Promise<FetchHistoricalSummaryResponse> => {
try {
const response = await http.post<FetchHistoricalSummaryResponse>(
'/internal/observability/slos/_historical_summary',
{
body: JSON.stringify({ sloIds: params.sloIds }),
signal: abortController.signal,
}
);
return response;
} catch (error) {
// ignore error for retrieving slos
}
return EMPTY_RESPONSE;
};

View file

@ -8,7 +8,7 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { anSLO } from '../../../data/slo';
import { anSLO } from '../../../data/slo/slo';
import { SloDetails as Component, Props } from './slo_details';
export default {

View file

@ -14,7 +14,7 @@ import { useLicense } from '../../hooks/use_license';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { render } from '../../utils/test_helper';
import { SloDetailsPage } from '.';
import { anSLO } from '../../data/slo';
import { anSLO } from '../../data/slo/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { paths } from '../../config';

View file

@ -21,7 +21,7 @@ import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { SLO_EDIT_FORM_DEFAULT_VALUES } from './constants';
import { anSLO } from '../../data/slo';
import { anSLO } from '../../data/slo/slo';
import { paths } from '../../config';
import { SloEditPage } from '.';
@ -368,7 +368,7 @@ describe('SLO Edit Page', () => {
"2f17deb0-725a-11ed-ab7c-4bb641cfc57e",
Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"description": "some description useful",
"indicator": Object {
"params": Object {
"filter": "baz: foo and bar > 2",
@ -378,7 +378,7 @@ describe('SLO Edit Page', () => {
},
"type": "sli.kql.custom",
},
"name": "irrelevant",
"name": "super important level service",
"objective": Object {
"target": 0.98,
},

View file

@ -8,13 +8,13 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { aForecastedSLO } from '../../../data/slo';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { aForecastedSLO } from '../../../../data/slo/slo';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { SloBadges as Component, Props } from './slo_badges';
export default {
component: Component,
title: 'app/SLO/ListPage/SloBadges',
title: 'app/SLO/ListPage/Badges/SloBadges',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -10,7 +10,8 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { SloStatusBadge } from './slo_status_badge';
import { SloForecastedBadge } from './slo_forecasted_badge';
import { SloIndicatorTypeBadge } from './slo_indicator_type_badge';
import { SloTimeWindowBadge } from './slo_time_window_badge';
export interface Props {
slo: SLOWithSummaryResponse;
@ -18,12 +19,13 @@ export interface Props {
export function SloBadges({ slo }: Props) {
return (
<EuiFlexGroup direction="row" responsive={false} gutterSize="m">
<EuiFlexGroup direction="row" responsive={false} gutterSize="s">
<SloStatusBadge slo={slo} />
<EuiFlexItem grow={false}>
<SloStatusBadge slo={slo} />
<SloIndicatorTypeBadge slo={slo} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SloForecastedBadge slo={slo} />
<SloTimeWindowBadge slo={slo} />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,35 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { SloIndicatorTypeBadge as Component, Props } from './slo_indicator_type_badge';
import {
aCustomKqlIndicator,
anApmAvailabilityIndicator,
anApmLatencyIndicator,
createSLO,
} from '../../../../data/slo/slo';
export default {
component: Component,
title: 'app/SLO/ListPage/Badges/SloIndicatorTypeBadge',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
export const WithCustomKql = Template.bind({});
WithCustomKql.args = { slo: createSLO({ indicator: aCustomKqlIndicator }) };
export const WithApmAvailability = Template.bind({});
WithApmAvailability.args = { slo: createSLO({ indicator: anApmAvailabilityIndicator }) };
export const WithApmLatency = Template.bind({});
WithApmLatency.args = { slo: createSLO({ indicator: anApmLatencyIndicator }) };

View file

@ -0,0 +1,45 @@
/*
* 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 { 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';
export interface Props {
slo: SLOWithSummaryResponse;
}
export function SloIndicatorTypeBadge({ slo }: Props) {
return (
<div>
<EuiBadge color={euiLightVars.euiColorDisabled}>
{toIndicatorLabel(slo.indicator.type)}
</EuiBadge>
</div>
);
}
function toIndicatorLabel(indicatorType: SLOWithSummaryResponse['indicator']['type']) {
switch (indicatorType) {
case 'sli.kql.custom':
return i18n.translate('xpack.observability.slos.slo.indicator.customKql', {
defaultMessage: 'KQL',
});
case 'sli.apm.transactionDuration':
return i18n.translate('xpack.observability.slos.slo.indicator.apmLatency', {
defaultMessage: 'Latency',
});
case 'sli.apm.transactionErrorRate':
return i18n.translate('xpack.observability.slos.slo.indicator.apmAvailability', {
defaultMessage: 'Availability',
});
default:
assertNever(indicatorType);
}
}

View file

@ -8,13 +8,13 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { SloStatusBadge as Component, SloStatusProps } from './slo_status_badge';
import { anSLO } from '../../../data/slo';
import { anSLO } from '../../../../data/slo/slo';
export default {
component: Component,
title: 'app/SLO/ListPage/SloStatusBadge',
title: 'app/SLO/ListPage/Badges/SloStatusBadge',
decorators: [KibanaReactStorybookDecorator],
};

View file

@ -0,0 +1,69 @@
/*
* 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 { EuiBadge, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
export interface SloStatusProps {
slo: SLOWithSummaryResponse;
}
export function SloStatusBadge({ slo }: SloStatusProps) {
return (
<>
<EuiFlexItem grow={false}>
<div>
{slo.summary.status === 'NO_DATA' && (
<EuiBadge color={euiLightVars.euiColorDisabled}>
{i18n.translate('xpack.observability.slos.slo.state.noData', {
defaultMessage: 'No data',
})}
</EuiBadge>
)}
{slo.summary.status === 'HEALTHY' && (
<EuiBadge color={euiLightVars.euiColorSuccess}>
{i18n.translate('xpack.observability.slos.slo.state.healthy', {
defaultMessage: 'Healthy',
})}
</EuiBadge>
)}
{slo.summary.status === 'DEGRADING' && (
<EuiBadge color={euiLightVars.euiColorWarning}>
{i18n.translate('xpack.observability.slos.slo.state.degrading', {
defaultMessage: 'Degrading',
})}
</EuiBadge>
)}
{slo.summary.status === 'VIOLATED' && (
<EuiBadge color={euiLightVars.euiColorDanger}>
{i18n.translate('xpack.observability.slos.slo.state.violated', {
defaultMessage: 'Violated',
})}
</EuiBadge>
)}
</div>
</EuiFlexItem>
{slo.summary.errorBudget.isEstimated && (
<EuiFlexItem grow={false}>
<div>
<EuiBadge color={euiLightVars.euiColorDisabled}>
{i18n.translate('xpack.observability.slos.slo.state.forecasted', {
defaultMessage: 'Forecasted',
})}
</EuiBadge>
</div>
</EuiFlexItem>
)}
</>
);
}

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 { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../../utils/kibana_react.storybook_decorator';
import { SloTimeWindowBadge as Component, Props } from './slo_time_window_badge';
import { createSLO } from '../../../../data/slo/slo';
export default {
component: Component,
title: 'app/SLO/ListPage/Badges/SloTimeWindowBadge',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
export const With7DaysRolling = Template.bind({});
With7DaysRolling.args = { slo: createSLO({ timeWindow: { duration: '7d', isRolling: true } }) };
export const With30DaysRolling = Template.bind({});
With30DaysRolling.args = { slo: createSLO({ timeWindow: { duration: '30d', isRolling: true } }) };
export const WithMonthlyCalendarStartingToday = Template.bind({});
WithMonthlyCalendarStartingToday.args = {
slo: createSLO({
timeWindow: { duration: '1M', calendar: { startTime: new Date().toISOString() } },
}),
};
export const WithMonthlyCalendar = Template.bind({});
WithMonthlyCalendar.args = {
slo: createSLO({
timeWindow: { duration: '1M', calendar: { startTime: '2022-01-01T00:00:00.000Z' } },
}),
};
export const WithBiWeeklyCalendar = Template.bind({});
WithBiWeeklyCalendar.args = {
slo: createSLO({
timeWindow: { duration: '2w', calendar: { startTime: '2023-01-01T00:00:00.000Z' } },
}),
};
export const WithQuarterlyCalendar = Template.bind({});
WithQuarterlyCalendar.args = {
slo: createSLO({
timeWindow: { duration: '1Q', calendar: { startTime: '2022-01-01T00:00:00.000Z' } },
}),
};

View file

@ -0,0 +1,108 @@
/*
* 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 moment from 'moment';
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
export interface Props {
slo: SLOWithSummaryResponse;
}
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
color={euiLightVars.euiColorDisabled}
iconType="editorItemAlignRight"
iconSide="left"
>
{label}
</EuiBadge>
</div>
);
}
const unitMoment = toMomentUnitOfTime(unit);
const now = moment.utc();
const startTime = moment.utc(slo.timeWindow.calendar.startTime);
const differenceInUnit = now.diff(startTime, unitMoment);
const periodStart = startTime
.clone()
.add(Math.floor(differenceInUnit / duration) * duration, unitMoment);
const periodEnd = periodStart.clone().add(duration, unitMoment);
const totalDurationInDays = periodEnd.diff(periodStart, 'days');
const elapsedDurationInDays = now.diff(periodStart, 'days') + 1;
return (
<div>
<EuiBadge color={euiLightVars.euiColorDisabled} iconType="calendar" iconSide="left">
{i18n.translate('xpack.observability.slos.slo.timeWindow.calendar', {
defaultMessage: '{elapsed}/{total} days',
values: {
elapsed: Math.min(elapsedDurationInDays, totalDurationInDays),
total: totalDurationInDays,
},
})}
</EuiBadge>
</div>
);
}
function toDurationLabel(duration: number, durationUnit: string) {
switch (durationUnit) {
case 'd':
return i18n.translate('xpack.observability.slos.slo.timeWindow.days', {
defaultMessage: '{duration} days',
values: { duration },
});
case 'w':
return i18n.translate('xpack.observability.slos.slo.timeWindow.weeks', {
defaultMessage: '{duration} weeks',
values: { duration },
});
case 'M':
return i18n.translate('xpack.observability.slos.slo.timeWindow.months', {
defaultMessage: '{duration} months',
values: { duration },
});
case 'Q':
return i18n.translate('xpack.observability.slos.slo.timeWindow.quarterss', {
defaultMessage: '{duration} quarters',
values: { duration },
});
case 'Y':
return i18n.translate('xpack.observability.slos.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

@ -13,7 +13,7 @@ import {
SloDeleteConfirmationModal as Component,
SloDeleteConfirmationModalProps,
} from './slo_delete_confirmation_modal';
import { anSLO } from '../../../data/slo';
import { anSLO } from '../../../data/slo/slo';
export default {
component: Component,

View file

@ -1,32 +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.
*/
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
export interface Props {
slo: SLOWithSummaryResponse;
}
export function SloForecastedBadge({ slo }: Props) {
if (!slo.summary.errorBudget.isEstimated) {
return null;
}
return (
<div>
<EuiBadge color={euiLightVars.euiColorDisabled}>
{i18n.translate('xpack.observability.slos.slo.state.forecasted', {
defaultMessage: 'Forecasted',
})}
</EuiBadge>
</div>
);
}

View file

@ -30,7 +30,7 @@ export function SloList() {
const {
loading,
error,
sloList: { results: slos = [], total, perPage },
sloList: { results: sloList = [], total, perPage },
} = useFetchSloList({
page: activePage + 1,
name: query,
@ -88,7 +88,7 @@ export function SloList() {
<EuiFlexItem>
<SloListItems
slos={slos}
sloList={sloList}
loading={loading}
error={error}
onDeleting={handleDeleting}
@ -96,7 +96,7 @@ export function SloList() {
/>
</EuiFlexItem>
{slos.length ? (
{sloList.length ? (
<EuiFlexItem>
<EuiFlexGroup direction="column" gutterSize="s" alignItems="flexEnd">
<EuiFlexItem>

View file

@ -8,7 +8,11 @@
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { anSLO } from '../../../data/slo';
import {
HEALTHY_ROLLING_SLO,
historicalSummaryData,
} from '../../../data/slo/historical_summary_data';
import { anSLO } from '../../../data/slo/slo';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloListItem as Component, SloListItemProps } from './slo_list_item';
@ -24,6 +28,7 @@ const Template: ComponentStory<typeof Component> = (props: SloListItemProps) =>
const defaultProps = {
slo: anSLO,
historicalSummary: historicalSummaryData[HEALTHY_ROLLING_SLO],
};
export const SloListItem = Template.bind({});

View file

@ -12,26 +12,34 @@ import {
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiPanel,
EuiPopover,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useKibana } from '../../../utils/kibana_react';
import { SloSummaryStats } from './slo_summary_stats';
import { SloSummary } from './slo_summary';
import { SloDeleteConfirmationModal } from './slo_delete_confirmation_modal';
import { SloBadges } from './slo_badges';
import { SloBadges } from './badges/slo_badges';
import { paths } from '../../../config';
export interface SloListItemProps {
slo: SLOWithSummaryResponse;
historicalSummary?: HistoricalSummaryResponse[];
historicalSummaryLoading: boolean;
onDeleted: () => void;
onDeleting: () => void;
}
export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
export function SloListItem({
slo,
historicalSummary = [],
historicalSummaryLoading,
onDeleted,
onDeleting,
}: SloListItemProps) {
const {
application: { navigateToUrl },
http: { basePath },
@ -55,10 +63,6 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
setIsActionsPopoverOpen(false);
};
const handleNavigate = () => {
navigateToUrl(basePath.prepend(paths.observability.sloDetails(slo.id)));
};
const handleDeleteCancel = () => {
setDeleteConfirmationModalOpen(false);
setIsDeleting(false);
@ -83,14 +87,18 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem>
<EuiLink onClick={handleNavigate}>{slo.name}</EuiLink>
<EuiText size="s">{slo.name}</EuiText>
</EuiFlexItem>
<SloBadges slo={slo} />
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SloSummaryStats slo={slo} />
<SloSummary
slo={slo}
historicalSummary={historicalSummary}
historicalSummaryLoading={historicalSummaryLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>

View file

@ -9,8 +9,8 @@ import React from 'react';
import { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloListItems as Component, SloListItemsProps } from './slo_list_items';
import { aForecastedSLO, anSLO } from '../../../data/slo';
import { SloListItems as Component, Props } from './slo_list_items';
import { aForecastedSLO, anSLO } from '../../../data/slo/slo';
export default {
component: Component,
@ -18,12 +18,10 @@ export default {
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: SloListItemsProps) => (
<Component {...props} />
);
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
const defaultProps: SloListItemsProps = {
slos: [anSLO, anSLO, aForecastedSLO],
const defaultProps: Props = {
sloList: [anSLO, anSLO, aForecastedSLO],
loading: false,
error: false,
onDeleted: () => {},

View file

@ -4,34 +4,52 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import React, { useState, useEffect } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { useFetchHistoricalSummary } from '../../../hooks/slo/use_fetch_historical_summary';
import { SloListItem } from './slo_list_item';
import { SloListEmpty } from './slo_list_empty';
import { SloListError } from './slo_list_error';
export interface SloListItemsProps {
slos: SLOWithSummaryResponse[];
export interface Props {
sloList: SLOWithSummaryResponse[];
loading: boolean;
error: boolean;
onDeleted: () => void;
onDeleting: () => void;
}
export function SloListItems({ slos, loading, error, onDeleted, onDeleting }: SloListItemsProps) {
export function SloListItems({ sloList, loading, error, onDeleted, onDeleting }: Props) {
const [sloIds, setSloIds] = useState<string[]>([]);
useEffect(() => {
setSloIds(sloList.map((slo) => slo.id));
}, [sloList]);
const { loading: historicalSummaryLoading, data: historicalSummaryBySlo } =
useFetchHistoricalSummary({ sloIds });
if (!loading && !error && sloList.length === 0) {
return <SloListEmpty />;
}
if (!loading && error) {
return <SloListError />;
}
return (
<EuiFlexGroup direction="column" gutterSize="s">
{slos.length
? slos.map((slo) => (
<EuiFlexItem key={slo.id}>
<SloListItem slo={slo} onDeleted={onDeleted} onDeleting={onDeleting} />
</EuiFlexItem>
))
: null}
{!loading && slos.length === 0 && !error ? <SloListEmpty /> : null}
{!loading && slos.length === 0 && error ? <SloListError /> : null}
{sloList.map((slo) => (
<EuiFlexItem key={slo.id}>
<SloListItem
slo={slo}
historicalSummary={historicalSummaryBySlo[slo.id]}
historicalSummaryLoading={historicalSummaryLoading}
onDeleted={onDeleted}
onDeleting={onDeleting}
/>
</EuiFlexItem>
))}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,123 @@
/*
* 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 { ComponentStory } from '@storybook/react';
import { HistoricalSummaryResponse } from '@kbn/slo-schema';
import {
HEALTHY_ROLLING_SLO,
HEALTHY_RANDOM_ROLLING_SLO,
HEALTHY_STEP_DOWN_ROLLING_SLO,
historicalSummaryData,
DEGRADING_FAST_ROLLING_SLO,
NO_DATA_TO_HEALTHY_ROLLING_SLO,
} from '../../../data/slo/historical_summary_data';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloSparkline as Component, Props } from './slo_sparkline';
export default {
component: Component,
title: 'app/SLO/ListPage/SloSparkline',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
export const AreaWithHealthyFlatData = Template.bind({});
AreaWithHealthyFlatData.args = {
chart: 'area',
state: 'success',
id: 'history',
data: toBudgetBurnDown(historicalSummaryData[HEALTHY_ROLLING_SLO]),
};
export const AreaWithHealthyRandomData = Template.bind({});
AreaWithHealthyRandomData.args = {
chart: 'area',
state: 'success',
id: 'history',
data: toBudgetBurnDown(historicalSummaryData[HEALTHY_RANDOM_ROLLING_SLO]),
};
export const AreaWithHealthyStepDownData = Template.bind({});
AreaWithHealthyStepDownData.args = {
chart: 'area',
state: 'success',
id: 'history',
data: toBudgetBurnDown(historicalSummaryData[HEALTHY_STEP_DOWN_ROLLING_SLO]),
};
export const AreaWithDegradingLinearData = Template.bind({});
AreaWithDegradingLinearData.args = {
chart: 'area',
state: 'error',
id: 'history',
data: toBudgetBurnDown(historicalSummaryData[DEGRADING_FAST_ROLLING_SLO]),
};
export const AreaWithNoDataToDegradingLinearData = Template.bind({});
AreaWithNoDataToDegradingLinearData.args = {
chart: 'area',
state: 'error',
id: 'history',
data: toBudgetBurnDown(historicalSummaryData[NO_DATA_TO_HEALTHY_ROLLING_SLO]),
};
export const LineWithHealthyFlatData = Template.bind({});
LineWithHealthyFlatData.args = {
chart: 'line',
state: 'success',
id: 'history',
data: toSliHistory(historicalSummaryData[HEALTHY_ROLLING_SLO]),
};
export const LineWithHealthyRandomData = Template.bind({});
LineWithHealthyRandomData.args = {
chart: 'line',
state: 'success',
id: 'history',
data: toSliHistory(historicalSummaryData[HEALTHY_RANDOM_ROLLING_SLO]),
};
export const LineWithHealthyStepDownData = Template.bind({});
LineWithHealthyStepDownData.args = {
chart: 'line',
state: 'success',
id: 'history',
data: toSliHistory(historicalSummaryData[HEALTHY_STEP_DOWN_ROLLING_SLO]),
};
export const LineWithDegradingLinearData = Template.bind({});
LineWithDegradingLinearData.args = {
chart: 'line',
state: 'error',
id: 'history',
data: toSliHistory(historicalSummaryData[DEGRADING_FAST_ROLLING_SLO]),
};
export const LineWithNoDataToDegradingLinearData = Template.bind({});
LineWithNoDataToDegradingLinearData.args = {
chart: 'line',
state: 'error',
id: 'history',
data: toSliHistory(historicalSummaryData[NO_DATA_TO_HEALTHY_ROLLING_SLO]),
};
function toBudgetBurnDown(data: HistoricalSummaryResponse[]) {
return data.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.status === 'NO_DATA' ? undefined : datum.errorBudget.remaining,
}));
}
function toSliHistory(data: HistoricalSummaryResponse[]) {
return data.map((datum) => ({
key: new Date(datum.date).getTime(),
value: datum.status === 'NO_DATA' ? undefined : datum.sliValue,
}));
}

View file

@ -0,0 +1,69 @@
/*
* 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 { AreaSeries, Chart, Fit, LineSeries, ScaleType, Settings } from '@elastic/charts';
import React from 'react';
import { EuiLoadingChart, useEuiTheme } from '@elastic/eui';
import { EUI_SPARKLINE_THEME_PARTIAL } from '@elastic/eui/dist/eui_charts_theme';
import { useKibana } from '../../../utils/kibana_react';
interface Data {
key: number;
value: number | undefined;
}
type ChartType = 'area' | 'line';
type State = 'success' | 'error';
export interface Props {
id: string;
data: Data[];
chart: ChartType;
state: State;
loading: boolean;
}
export function SloSparkline({ chart, data, id, loading, state }: Props) {
const charts = useKibana().services.charts;
const theme = charts.theme.useChartsTheme();
const baseTheme = charts.theme.useChartsBaseTheme();
const { euiTheme } = useEuiTheme();
const color = state === 'error' ? euiTheme.colors.danger : euiTheme.colors.success;
const ChartComponent = chart === 'area' ? AreaSeries : LineSeries;
if (loading) {
return <EuiLoadingChart size="m" mono />;
}
return (
<Chart size={{ height: 28, width: 80 }}>
<Settings
baseTheme={baseTheme}
showLegend={false}
theme={[theme, EUI_SPARKLINE_THEME_PARTIAL]}
tooltip="none"
/>
<ChartComponent
color={color}
data={data}
fit={Fit.Nearest}
id={id}
lineSeriesStyle={{
line: {
strokeWidth: 1,
},
point: { visible: false },
}}
xAccessor={'key'}
xScaleType={ScaleType.Time}
yAccessors={['value']}
yScaleType={ScaleType.Linear}
/>
</Chart>
);
}

View file

@ -1,54 +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.
*/
import React from 'react';
import { EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { euiLightVars } from '@kbn/ui-theme';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
export interface SloStatusProps {
slo: SLOWithSummaryResponse;
}
export function SloStatusBadge({ slo }: SloStatusProps) {
return (
<div>
{slo.summary.status === 'NO_DATA' && (
<EuiBadge color={euiLightVars.euiColorDisabled}>
{i18n.translate('xpack.observability.slos.slo.state.noData', {
defaultMessage: 'No data',
})}
</EuiBadge>
)}
{slo.summary.status === 'HEALTHY' && (
<EuiBadge color={euiLightVars.euiColorSuccess}>
{i18n.translate('xpack.observability.slos.slo.state.healthy', {
defaultMessage: 'Healthy',
})}
</EuiBadge>
)}
{slo.summary.status === 'DEGRADING' && (
<EuiBadge color={euiLightVars.euiColorWarning}>
{i18n.translate('xpack.observability.slos.slo.state.degrading', {
defaultMessage: 'Degrading',
})}
</EuiBadge>
)}
{slo.summary.status === 'VIOLATED' && (
<EuiBadge color={euiLightVars.euiColorDanger}>
{i18n.translate('xpack.observability.slos.slo.state.violated', {
defaultMessage: 'Violated',
})}
</EuiBadge>
)}
</div>
);
}

View file

@ -9,20 +9,29 @@ import React from 'react';
import { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloForecastedBadge as Component, Props } from './slo_forecasted_badge';
import { aForecastedSLO } from '../../../data/slo';
import {
HEALTHY_ROLLING_SLO,
historicalSummaryData,
} from '../../../data/slo/historical_summary_data';
import { createSLO } from '../../../data/slo/slo';
import { SloSummary as Component, Props } from './slo_summary';
export default {
component: Component,
title: 'app/SLO/ListPage/SloForecastedBadge',
title: 'app/SLO/ListPage/SloSummary',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
const defaultProps = {
slo: aForecastedSLO,
slo: createSLO(),
historicalSummary: historicalSummaryData[HEALTHY_ROLLING_SLO],
historicalSummaryLoading: false,
};
export const SloForecastedBadge = Template.bind({});
SloForecastedBadge.args = defaultProps;
export const WithHistoricalData = Template.bind({});
WithHistoricalData.args = { ...defaultProps };
export const WithLoadingData = Template.bind({});
WithLoadingData.args = { ...defaultProps, historicalSummaryLoading: true };

View file

@ -0,0 +1,93 @@
/*
* 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, EuiStat } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { asPercentWithTwoDecimals } from '../../../../common/utils/formatters';
import { SloSparkline } from './slo_sparkline';
export interface Props {
slo: SLOWithSummaryResponse;
historicalSummary?: HistoricalSummaryResponse[];
historicalSummaryLoading: boolean;
}
export function SloSummary({ slo, historicalSummary = [], historicalSummaryLoading }: Props) {
const isSloFailed = slo.summary.status === 'VIOLATED' || slo.summary.status === 'DEGRADING';
const titleColor = isSloFailed ? 'danger' : '';
const historicalSliData = historicalSummary.map((data) => ({
key: new Date(data.date).getTime(),
value: data.status === 'NO_DATA' ? undefined : data.sliValue,
}));
const errorBudgetBurnDownData = historicalSummary.map((data) => ({
key: new Date(data.date).getTime(),
value: data.status === 'NO_DATA' ? undefined : data.errorBudget.remaining,
}));
return (
<EuiFlexGroup direction="row" justifyContent="spaceBetween" gutterSize="xl">
<EuiFlexItem grow={false} style={{ width: 210 }}>
<EuiFlexGroup direction="row" responsive={false} gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.objective', {
defaultMessage: '{objective} target',
values: { objective: asPercentWithTwoDecimals(slo.objective.target, 1) },
})}
title={
slo.summary.status === 'NO_DATA'
? NOT_AVAILABLE_LABEL
: asPercentWithTwoDecimals(slo.summary.sliValue, 1)
}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SloSparkline
chart="line"
id="sli_history"
state={isSloFailed ? 'error' : 'success'}
data={historicalSliData}
loading={historicalSummaryLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 210 }}>
<EuiFlexGroup direction="row" responsive={false} gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.budgetRemaining', {
defaultMessage: 'Budget remaining',
})}
title={asPercentWithTwoDecimals(slo.summary.errorBudget.remaining, 1)}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
<EuiFlexItem>
<SloSparkline
chart="area"
id="error_budget_burn_down"
state={isSloFailed ? 'error' : 'success'}
data={errorBudgetBurnDownData}
loading={historicalSummaryLoading}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,29 +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.
*/
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { anSLO } from '../../../data/slo';
import { SloSummaryStats as Component, SloSummaryStatsProps } from './slo_summary_stats';
export default {
component: Component,
title: 'app/SLO/ListPage/SloSummaryStats',
argTypes: {},
};
const Template: ComponentStory<typeof Component> = (props: SloSummaryStatsProps) => (
<Component {...props} />
);
const defaultProps = {
slo: anSLO,
};
export const SloSummaryStats = Template.bind({});
SloSummaryStats.args = defaultProps;

View file

@ -1,83 +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.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiStat } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { asPercentWithTwoDecimals } from '../../../../common/utils/formatters';
import { getSloDifference } from '../helpers/get_slo_difference';
export interface SloSummaryStatsProps {
slo: SLOWithSummaryResponse;
}
export function SloSummaryStats({ slo }: SloSummaryStatsProps) {
const titleColor = slo.summary.status === 'VIOLATED' ? 'danger' : '';
const { label } = getSloDifference(slo);
return (
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiFlexGroup direction="row" responsive={false}>
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.observedValue', {
defaultMessage: 'Observed value',
})}
title={
slo.summary.status === 'NO_DATA'
? NOT_AVAILABLE_LABEL
: asPercentWithTwoDecimals(slo.summary.sliValue, 1)
}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 110 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.objective', {
defaultMessage: 'Objective',
})}
title={asPercentWithTwoDecimals(slo.objective.target, 1)}
titleSize="m"
reverse
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row" responsive={false}>
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.difference', {
defaultMessage: 'Difference',
})}
title={label}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: 120 }}>
<EuiStat
description={i18n.translate('xpack.observability.slos.slo.stats.budgetRemaining', {
defaultMessage: 'Budget remaining',
})}
title={asPercentWithTwoDecimals(slo.summary.errorBudget.remaining, 1)}
titleColor={titleColor}
titleSize="m"
reverse
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,25 +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.
*/
import { SLOWithSummaryResponse } from '@kbn/slo-schema';
import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n';
import { asPercent } from '../../../../common/utils/formatters';
export function getSloDifference(slo: SLOWithSummaryResponse) {
if (slo.summary.status === 'NO_DATA') {
return {
value: Number.NaN,
label: NOT_AVAILABLE_LABEL,
};
}
const difference = slo.summary.sliValue - slo.objective.target;
return {
value: difference,
label: `${difference > 0 ? '+' : ''}${asPercent(difference, 1)}`,
};
}

View file

@ -11,11 +11,14 @@ import { screen } from '@testing-library/react';
import { render } from '../../utils/test_helper';
import { useKibana } from '../../utils/kibana_react';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { useFetchHistoricalSummary } from '../../hooks/slo/use_fetch_historical_summary';
import { useLicense } from '../../hooks/use_license';
import { SlosPage } from '.';
import { emptySloList, sloList } from '../../data/slo';
import { emptySloList, sloList } from '../../data/slo/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { historicalSummaryData } from '../../data/slo/historical_summary_data';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -26,10 +29,12 @@ jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../hooks/slo/use_fetch_historical_summary');
const useKibanaMock = useKibana as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useFetchSloListMock = useFetchSloList as jest.Mock;
const useFetchHistoricalSummaryMock = useFetchHistoricalSummary as jest.Mock;
const mockNavigate = jest.fn();
@ -37,6 +42,7 @@ const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
charts: chartPluginMock.createSetupContract(),
http: {
basePath: {
prepend: jest.fn(),
@ -105,6 +111,10 @@ describe('SLOs Page', () => {
it('renders the SLOs page when the API has finished loading and there are results', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchHistoricalSummaryMock.mockReturnValue({
loading: false,
data: historicalSummaryData,
});
render(<SlosPage />, config);

View file

@ -11,6 +11,12 @@ export function KibanaReactStorybookDecorator(Story: ComponentType) {
return (
<KibanaContextProvider
services={{
charts: {
theme: {
useChartsTheme: () => {},
useChartsBaseTheme: () => {},
},
},
application: { navigateToUrl: () => {} },
http: { basePath: { prepend: (_: string) => '' } },
docLinks: { links: { query: {} } },

View file

@ -6,14 +6,15 @@
*/
import { TimeWindow } from '../models/time_window';
import { Duration, DurationUnit } from '../models';
import { Duration } from '../models';
import { toDateRange } from './date_range';
const THIRTY_DAYS = new Duration(30, DurationUnit.Day);
const WEEKLY = new Duration(1, DurationUnit.Week);
const BIWEEKLY = new Duration(2, DurationUnit.Week);
const MONTHLY = new Duration(1, DurationUnit.Month);
const QUARTERLY = new Duration(1, DurationUnit.Quarter);
import {
oneMonth,
oneQuarter,
oneWeek,
thirtyDays,
twoWeeks,
} from '../../services/slo/fixtures/duration';
const NOW = new Date('2022-08-11T08:31:00.000Z');
@ -23,7 +24,7 @@ describe('toDateRange', () => {
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const timeWindow = aCalendarTimeWindow(WEEKLY, futureDate);
const timeWindow = aCalendarTimeWindow(oneWeek(), futureDate);
expect(() => toDateRange(timeWindow, NOW)).toThrow(
'Cannot compute date range with future starting time'
);
@ -31,7 +32,7 @@ describe('toDateRange', () => {
describe("with 'weekly' duration", () => {
it('computes the date range when starting the same day', () => {
const timeWindow = aCalendarTimeWindow(WEEKLY, new Date('2022-08-11T08:30:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneWeek(), new Date('2022-08-11T08:30:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-11T08:30:00.000Z'),
to: new Date('2022-08-18T08:30:00.000Z'),
@ -39,7 +40,7 @@ describe('toDateRange', () => {
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(WEEKLY, new Date('2022-07-05T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneWeek(), new Date('2022-07-05T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-09T08:00:00.000Z'),
to: new Date('2022-08-16T08:00:00.000Z'),
@ -49,7 +50,7 @@ describe('toDateRange', () => {
describe("with 'bi-weekly' duration", () => {
it('computes the date range when starting the same day', () => {
const timeWindow = aCalendarTimeWindow(BIWEEKLY, new Date('2022-08-11T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(twoWeeks(), new Date('2022-08-11T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-11T08:00:00.000Z'),
to: new Date('2022-08-25T08:00:00.000Z'),
@ -57,17 +58,17 @@ describe('toDateRange', () => {
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(BIWEEKLY, new Date('2022-07-05T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(twoWeeks(), new Date('2022-07-05T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-09T08:00:00.000Z'),
to: new Date('2022-08-23T08:00:00.000Z'),
from: new Date('2022-08-02T08:00:00.000Z'),
to: new Date('2022-08-16T08:00:00.000Z'),
});
});
});
describe("with 'monthly' duration", () => {
it('computes the date range when starting the same month', () => {
const timeWindow = aCalendarTimeWindow(MONTHLY, new Date('2022-08-01T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneMonth(), new Date('2022-08-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
@ -75,7 +76,7 @@ describe('toDateRange', () => {
});
it('computes the date range when starting a month ago', () => {
const timeWindow = aCalendarTimeWindow(MONTHLY, new Date('2022-07-01T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneMonth(), new Date('2022-07-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-08-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
@ -85,7 +86,7 @@ describe('toDateRange', () => {
describe("with 'quarterly' duration", () => {
it('computes the date range when starting the same quarter', () => {
const timeWindow = aCalendarTimeWindow(QUARTERLY, new Date('2022-07-01T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneQuarter(), new Date('2022-07-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-07-01T08:00:00.000Z'),
to: new Date('2022-10-01T08:00:00.000Z'),
@ -93,7 +94,7 @@ describe('toDateRange', () => {
});
it('computes the date range when starting a quarter ago', () => {
const timeWindow = aCalendarTimeWindow(QUARTERLY, new Date('2022-03-01T08:00:00.000Z'));
const timeWindow = aCalendarTimeWindow(oneQuarter(), new Date('2022-03-01T08:00:00.000Z'));
expect(toDateRange(timeWindow, NOW)).toEqual({
from: new Date('2022-06-01T08:00:00.000Z'),
to: new Date('2022-09-01T08:00:00.000Z'),
@ -104,28 +105,28 @@ describe('toDateRange', () => {
describe('for rolling time window', () => {
it("computes the date range using a '30days' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(THIRTY_DAYS), NOW)).toEqual({
expect(toDateRange(aRollingTimeWindow(thirtyDays()), NOW)).toEqual({
from: new Date('2022-07-12T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'weekly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(WEEKLY), NOW)).toEqual({
expect(toDateRange(aRollingTimeWindow(oneWeek()), NOW)).toEqual({
from: new Date('2022-08-04T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'monthly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(MONTHLY), NOW)).toEqual({
expect(toDateRange(aRollingTimeWindow(oneMonth()), NOW)).toEqual({
from: new Date('2022-07-11T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});
});
it("computes the date range using a 'quarterly' rolling window", () => {
expect(toDateRange(aRollingTimeWindow(QUARTERLY), NOW)).toEqual({
expect(toDateRange(aRollingTimeWindow(oneQuarter()), NOW)).toEqual({
from: new Date('2022-05-11T08:31:00.000Z'),
to: new Date('2022-08-11T08:31:00.000Z'),
});

View file

@ -23,7 +23,12 @@ export const toDateRange = (timeWindow: TimeWindow, currentDate: Date = new Date
throw new Error('Cannot compute date range with future starting time');
}
const from = startTime.clone().add(differenceInUnit, unit);
const from = startTime
.clone()
.add(
Math.floor(differenceInUnit / timeWindow.duration.value) * timeWindow.duration.value,
unit
);
const to = from.clone().add(timeWindow.duration.value, unit);
return { from: from.toDate(), to: to.toDate() };

View file

@ -7,6 +7,10 @@
import { Duration, DurationUnit } from '../../../domain/models';
export function oneQuarter(): Duration {
return new Duration(1, DurationUnit.Quarter);
}
export function thirtyDays(): Duration {
return new Duration(30, DurationUnit.Day);
}
@ -23,6 +27,10 @@ export function oneWeek(): Duration {
return new Duration(1, DurationUnit.Week);
}
export function twoWeeks(): Duration {
return new Duration(2, DurationUnit.Week);
}
export function sixHours(): Duration {
return new Duration(6, DurationUnit.Hour);
}