mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
feat(slo): dashboard with sparkline and badges (#149445)
This commit is contained in:
parent
da83d96ff6
commit
088a6bb5af
38 changed files with 2630 additions and 310 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
|
@ -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 }) };
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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],
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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' } },
|
||||
}),
|
||||
};
|
|
@ -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';
|
||||
}
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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: () => {},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)}`,
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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: {} } },
|
||||
|
|
|
@ -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'),
|
||||
});
|
||||
|
|
|
@ -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() };
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue