Add Platinum license check for SLO APIs and SLO pages (#149055)

Closes https://github.com/elastic/kibana/issues/148298
This commit is contained in:
Coen Warmer 2023-01-18 17:52:47 +01:00 committed by GitHub
parent bcd4260154
commit 5854bceb62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 811 additions and 518 deletions

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 { useCallback } from 'react';
import { Observable } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import type { ILicense, LicenseType } from '@kbn/licensing-plugin/public';
import { useKibana } from '../utils/kibana_react';
interface UseLicenseReturnValue {
getLicense: () => ILicense | null;
hasAtLeast: (level: LicenseType) => boolean | undefined;
}
export const useLicense = (): UseLicenseReturnValue => {
const { licensing } = useKibana().services;
const license = useObservable<ILicense | null>(licensing?.license$ ?? new Observable(), null);
return {
getLicense: () => license,
hasAtLeast: useCallback(
(level: LicenseType) => {
if (!license) return;
return !!license && license.isAvailable && license.isActive && license.hasAtLeast(level);
},
[license]
),
};
};

View file

@ -8,15 +8,16 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { useKibana } from '../../utils/kibana_react';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { useParams } from 'react-router-dom';
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 { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useParams } from 'react-router-dom';
import { anSLO } from '../../data/slo';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
import { paths } from '../../config';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -24,19 +25,25 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/slo/use_fetch_slo_details');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/slo/use_fetch_slo_details');
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;
const useParamsMock = useParams as jest.Mock;
const useKibanaMock = useKibana as jest.Mock;
const useParamsMock = useParams as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;
const mockNavigate = jest.fn();
const mockBasePathPrepend = jest.fn();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
...kibanaStartMock.startContract(),
application: { navigateToUrl: mockNavigate },
http: {
basePath: {
prepend: jest.fn(),
prepend: mockBasePathPrepend,
},
},
},
@ -55,38 +62,64 @@ describe('SLO Details Page', () => {
mockKibana();
});
it('renders the not found page when the feature flag is not enabled', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
render(<SloDetailsPage />, { unsafe: { slo: { enabled: false } } });
describe('when the feature flag is not enabled', () => {
it('renders the not found page', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
render(<SloDetailsPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
});
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('inexistant');
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloDetailsPage />, config);
describe('when the feature flag is enabled', () => {
describe('when the incorrect license is found', () => {
it('navigates to the SLO List page', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
render(<SloDetailsPage />, { unsafe: { slo: { enabled: true } } });
it('renders the loading spiner when fetching the SLO', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: true, slo: undefined });
render(<SloDetailsPage />, config);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('loadingDetails')).toBeTruthy();
});
describe('when the correct license is found', () => {
it('renders the not found page when the SLO cannot be found', async () => {
useParamsMock.mockReturnValue('inexistant');
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
it('renders the SLO details page when the feature flag is enabled', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
render(<SloDetailsPage />, config);
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
expect(screen.queryByTestId('sloDetails')).toBeTruthy();
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
it('renders the loading spinner when fetching the SLO', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: true, slo: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('pageNotFound')).toBeFalsy();
expect(screen.queryByTestId('loadingTitle')).toBeTruthy();
expect(screen.queryByTestId('loadingDetails')).toBeTruthy();
});
it('renders the SLO details page', async () => {
useParamsMock.mockReturnValue(anSLO.id);
useFetchSloDetailsMock.mockReturnValue({ loading: false, slo: anSLO });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SloDetailsPage />, config);
expect(screen.queryByTestId('sloDetailsPage')).toBeTruthy();
expect(screen.queryByTestId('sloDetails')).toBeTruthy();
});
});
});
});

View file

@ -6,39 +6,51 @@
*/
import React from 'react';
import { useParams } from 'react-router-dom';
import { IBasePath } from '@kbn/core-http-browser';
import { EuiBreadcrumbProps } from '@elastic/eui/src/components/breadcrumbs/breadcrumb';
import { EuiLoadingSpinner } from '@elastic/eui';
import { SLOResponse } from '@kbn/slo-schema';
import { ObservabilityAppServices } from '../../application/types';
import { paths } from '../../config';
import { i18n } from '@kbn/i18n';
import type { IBasePath } from '@kbn/core-http-browser';
import type { SLOResponse } from '@kbn/slo-schema';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useKibana } from '../../utils/kibana_react';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useLicense } from '../../hooks/use_license';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
import { SLOS_BREADCRUMB_TEXT } from '../slos/translations';
import { SloDetailsPathParams } from './types';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { SloDetails } from './components/slo_details';
import { SLO_DETAILS_BREADCRUMB_TEXT } from './translations';
import { PageTitle } from './components/page_title';
import { paths } from '../../config';
import type { SloDetailsPathParams } from './types';
import type { ObservabilityAppServices } from '../../application/types';
export function SloDetailsPage() {
const { http } = useKibana<ObservabilityAppServices>().services;
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { sloId } = useParams<SloDetailsPathParams>();
const { hasAtLeast } = useLicense();
const hasRightLicense = hasAtLeast('platinum');
const { loading, slo } = useFetchSloDetails(sloId);
useBreadcrumbs(getBreadcrumbs(http.basePath, slo));
useBreadcrumbs(getBreadcrumbs(basePath, slo));
const isSloNotFound = !loading && slo === undefined;
if (!isSloFeatureEnabled(config) || isSloNotFound) {
return <PageNotFound />;
}
if (hasRightLicense === false) {
navigateToUrl(basePath.prepend(paths.observability.slos));
}
return (
<ObservabilityPageTemplate
pageHeader={{
@ -58,10 +70,16 @@ function getBreadcrumbs(basePath: IBasePath, slo: SLOResponse | undefined): EuiB
return [
{
href: basePath.prepend(paths.observability.slos),
text: SLOS_BREADCRUMB_TEXT,
text: i18n.translate('xpack.observability.breadcrumbs.slosLinkText', {
defaultMessage: 'SLOs',
}),
},
{
text: slo?.name ?? SLO_DETAILS_BREADCRUMB_TEXT,
text:
slo?.name ??
i18n.translate('xpack.observability.breadcrumbs.sloDetailsLinkText', {
defaultMessage: 'Details',
}),
},
];
}

View file

@ -1,19 +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 { i18n } from '@kbn/i18n';
export const SLO_DETAILS_PAGE_TITLE = i18n.translate('xpack.observability.sloDetailsPageTitle', {
defaultMessage: 'SLO Details',
});
export const SLO_DETAILS_BREADCRUMB_TEXT = i18n.translate(
'xpack.observability.breadcrumbs.sloDetailsLinkText',
{
defaultMessage: 'Details',
}
);

View file

@ -1,79 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SLO Edit Page when a sloId route param is provided calls the updateSlo hook if all required values are filled in 1`] = `
[MockFunction] {
"calls": Array [
Array [
"2f17deb0-725a-11ed-ab7c-4bb641cfc57e",
Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"indicator": Object {
"params": Object {
"filter": "baz: foo and bar > 2",
"good": "http_status: 2xx",
"index": "some-index",
"total": "a query",
},
"type": "sli.kql.custom",
},
"name": "irrelevant",
"objective": Object {
"target": 0.98,
},
"settings": Object {
"frequency": "1m",
"syncDelay": "1m",
"timestampField": "@timestamp",
},
"timeWindow": Object {
"duration": "30d",
"isRolling": true,
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;
exports[`SLO Edit Page when no sloId route param is provided calls the createSlo hook if all required values are filled in 1`] = `
[MockFunction] {
"calls": Array [
Array [
Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"indicator": Object {
"params": Object {
"filter": "irrelevant",
"good": "irrelevant",
"index": "some-index",
"total": "irrelevant",
},
"type": "sli.kql.custom",
},
"name": "irrelevant",
"objective": Object {
"target": 0.985,
},
"timeWindow": Object {
"duration": "7d",
"isRolling": true,
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect } from 'react';
import {
EuiAvatar,
EuiButton,
@ -73,28 +73,31 @@ export function SloEditForm({ slo }: Props) {
}
};
if (success) {
toasts.addSuccess(
isEditMode
? i18n.translate('xpack.observability.slos.sloEdit.update.success', {
defaultMessage: 'Successfully updated {name}',
values: { name: getValues().name },
})
: i18n.translate('xpack.observability.slos.sloEdit.creation.success', {
defaultMessage: 'Successfully created {name}',
values: { name: getValues().name },
})
);
navigateToUrl(basePath.prepend(paths.observability.slos));
}
useEffect(() => {
if (success) {
toasts.addSuccess(
isEditMode
? i18n.translate('xpack.observability.slos.sloEdit.update.success', {
defaultMessage: 'Successfully updated {name}',
values: { name: getValues().name },
})
: i18n.translate('xpack.observability.slos.sloEdit.creation.success', {
defaultMessage: 'Successfully created {name}',
values: { name: getValues().name },
})
);
if (error) {
toasts.addError(new Error(error), {
title: i18n.translate('xpack.observability.slos.sloEdit.creation.error', {
defaultMessage: 'Something went wrong',
}),
});
}
navigateToUrl(basePath.prepend(paths.observability.slos));
}
if (error) {
toasts.addError(new Error(error), {
title: i18n.translate('xpack.observability.slos.sloEdit.creation.error', {
defaultMessage: 'Something went wrong',
}),
});
}
}, [success, error, toasts, isEditMode, getValues, navigateToUrl, basePath]);
return (
<EuiTimeline data-test-subj="sloForm">

View file

@ -8,12 +8,12 @@
import React from 'react';
import Router from 'react-router-dom';
import { waitFor, fireEvent, screen } from '@testing-library/dom';
import { BasePath } from '@kbn/core-http-server-internal';
import { cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../utils/test_helper';
import { useKibana } from '../../utils/kibana_react';
import { useLicense } from '../../hooks/use_license';
import { useFetchIndices } from '../../hooks/use_fetch_indices';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useCreateOrUpdateSlo } from '../../hooks/slo/use_create_slo';
@ -31,6 +31,7 @@ jest.mock('react-router-dom', () => ({
}));
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/use_fetch_indices');
jest.mock('../../hooks/slo/use_fetch_slo_details');
jest.mock('../../hooks/slo/use_create_slo');
@ -41,6 +42,8 @@ jest.mock('../../utils/kibana_react', () => ({
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
const useKibanaMock = useKibana as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useFetchIndicesMock = useFetchIndices as jest.Mock;
const useFetchSloMock = useFetchSloDetails as jest.Mock;
const useCreateOrUpdateSloMock = useCreateOrUpdateSlo as jest.Mock;
@ -48,21 +51,26 @@ const useCreateOrUpdateSloMock = useCreateOrUpdateSlo as jest.Mock;
const mockAddSuccess = jest.fn();
const mockAddError = jest.fn();
const mockNavigate = jest.fn();
const mockBasePathPrepend = jest.fn();
(useKibana as jest.Mock).mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
http: {
basePath: new BasePath('', undefined),
},
notifications: {
toasts: {
addSuccess: mockAddSuccess,
addError: mockAddError,
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
http: {
basePath: {
prepend: mockBasePathPrepend,
},
},
notifications: {
toasts: {
addSuccess: mockAddSuccess,
addError: mockAddError,
},
},
},
},
});
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
@ -73,6 +81,7 @@ const config: Subset<ConfigSchema> = {
describe('SLO Edit Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
// Silence all the ref errors in Eui components.
jest.spyOn(console, 'error').mockImplementation(() => {});
@ -84,6 +93,7 @@ describe('SLO Edit Page', () => {
it('renders the not found page when no sloId param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
@ -94,6 +104,7 @@ describe('SLO Edit Page', () => {
it('renders the not found page when sloId param is passed', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
@ -102,270 +113,387 @@ describe('SLO Edit Page', () => {
});
});
describe('when no sloId route param is provided', () => {
it('renders the SLO Edit page in pristine state', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
describe('when the feature flag is enabled', () => {
describe('when the incorrect license is found', () => {
it('navigates to the SLO List page', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
: ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.name
);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.description
);
});
it.skip('calls the createSlo hook if all required values are filled in', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: mockCreate,
updateSlo: mockUpdate,
});
render(<SloEditPage />, config);
userEvent.type(screen.getByTestId('sloFormCustomKqlIndexInput'), 'some-index');
userEvent.type(screen.getByTestId('sloFormCustomKqlFilterQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlGoodQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlTotalQueryInput'), 'irrelevant');
userEvent.selectOptions(screen.getByTestId('sloFormBudgetingMethodSelect'), 'occurrences');
userEvent.selectOptions(screen.getByTestId('sloFormTimeWindowDurationSelect'), '7d');
userEvent.clear(screen.getByTestId('sloFormObjectiveTargetInput'));
userEvent.type(screen.getByTestId('sloFormObjectiveTargetInput'), '98.5');
userEvent.type(screen.getByTestId('sloFormNameInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormDescriptionTextArea'), 'irrelevant');
const t = Date.now();
await waitFor(() => expect(screen.getByTestId('sloFormSubmitButton')).toBeEnabled());
console.log('end waiting for submit button: ', Math.ceil(Date.now() - t));
fireEvent.click(screen.getByTestId('sloFormSubmitButton')!);
expect(mockCreate).toMatchSnapshot();
});
});
describe('when a sloId route param is provided', () => {
it('renders the SLO Edit page with prefilled form values', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(anSLO.indicator.type);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
anSLO.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.filter : ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.good : ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.total : ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
anSLO.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
anSLO.timeWindow.duration
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
anSLO.objective.target * 100
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(anSLO.name);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(anSLO.description);
});
it('calls the updateSlo hook if all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: mockCreate,
updateSlo: mockUpdate,
});
render(<SloEditPage />, config);
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
expect(mockUpdate).toMatchSnapshot();
});
it('blocks submitting if not all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: { ...anSLO, name: '' } });
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
});
describe('if submitting has completed successfully', () => {
it('renders a success toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockAddSuccess).toBeCalled();
});
describe('when the correct license is found', () => {
describe('when no sloId route param is provided', () => {
it('renders the SLO Edit page in pristine state', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
it('navigates to the SLO List page', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
});
});
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
describe('if submitting has not completed successfully', () => {
it('renders an error toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
: ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
: ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.name
);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
SLO_EDIT_FORM_DEFAULT_VALUES.description
);
});
it.skip('calls the createSlo hook if all required values are filled in', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: mockCreate,
updateSlo: mockUpdate,
});
render(<SloEditPage />, config);
userEvent.type(screen.getByTestId('sloFormCustomKqlIndexInput'), 'some-index');
userEvent.type(screen.getByTestId('sloFormCustomKqlFilterQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlGoodQueryInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormCustomKqlTotalQueryInput'), 'irrelevant');
userEvent.selectOptions(
screen.getByTestId('sloFormBudgetingMethodSelect'),
'occurrences'
);
userEvent.selectOptions(screen.getByTestId('sloFormTimeWindowDurationSelect'), '7d');
userEvent.clear(screen.getByTestId('sloFormObjectiveTargetInput'));
userEvent.type(screen.getByTestId('sloFormObjectiveTargetInput'), '98.5');
userEvent.type(screen.getByTestId('sloFormNameInput'), 'irrelevant');
userEvent.type(screen.getByTestId('sloFormDescriptionTextArea'), 'irrelevant');
const t = Date.now();
await waitFor(() => expect(screen.getByTestId('sloFormSubmitButton')).toBeEnabled());
console.log('end waiting for submit button: ', Math.ceil(Date.now() - t));
fireEvent.click(screen.getByTestId('sloFormSubmitButton')!);
expect(mockCreate).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"indicator": Object {
"params": Object {
"filter": "irrelevant",
"good": "irrelevant",
"index": "some-index",
"total": "irrelevant",
},
"type": "sli.kql.custom",
},
"name": "irrelevant",
"objective": Object {
"target": 0.985,
},
"timeWindow": Object {
"duration": "7d",
"isRolling": true,
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`);
});
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: 'Argh, API died',
createSlo: jest.fn(),
updateSlo: jest.fn(),
describe('when a sloId route param is provided', () => {
it('renders the SLO Edit page with prefilled form values', async () => {
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
expect(screen.queryByTestId('sloForm')).toBeTruthy();
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
anSLO.indicator.type
);
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
anSLO.indicator.params.index
);
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.filter : ''
);
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.good : ''
);
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.total : ''
);
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
anSLO.budgetingMethod
);
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
anSLO.timeWindow.duration
);
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
anSLO.objective.target * 100
);
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(anSLO.name);
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(anSLO.description);
});
it('calls the updateSlo hook if all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
const mockCreate = jest.fn();
const mockUpdate = jest.fn();
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: '',
createSlo: mockCreate,
updateSlo: mockUpdate,
});
render(<SloEditPage />, config);
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
expect(mockUpdate).toMatchInlineSnapshot(`
[MockFunction] {
"calls": Array [
Array [
"2f17deb0-725a-11ed-ab7c-4bb641cfc57e",
Object {
"budgetingMethod": "occurrences",
"description": "irrelevant",
"indicator": Object {
"params": Object {
"filter": "baz: foo and bar > 2",
"good": "http_status: 2xx",
"index": "some-index",
"total": "a query",
},
"type": "sli.kql.custom",
},
"name": "irrelevant",
"objective": Object {
"target": 0.98,
},
"settings": Object {
"frequency": "1m",
"syncDelay": "1m",
"timestampField": "@timestamp",
},
"timeWindow": Object {
"duration": "30d",
"isRolling": true,
},
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`);
});
it('blocks submitting if not all required values are filled in', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [],
});
useFetchSloMock.mockReturnValue({ loading: false, slo: { ...anSLO, name: '' } });
render(<SloEditPage />, config);
await waitFor(() => {
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
});
});
});
describe('when submitting has completed successfully', () => {
it('renders a success toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockAddSuccess).toBeCalled();
});
it('navigates to the SLO List page', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: true,
error: '',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockNavigate).toBeCalledWith(mockBasePathPrepend(paths.observability.slos));
});
});
describe('when submitting has not completed successfully', () => {
it('renders an error toast', async () => {
// Note: the `anSLO` object is considered to have (at least)
// values for all required fields.
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
useFetchIndicesMock.mockReturnValue({
loading: false,
indices: [{ name: 'some-index' }],
});
useCreateOrUpdateSloMock.mockReturnValue({
loading: false,
success: false,
error: 'Argh, API died',
createSlo: jest.fn(),
updateSlo: jest.fn(),
});
render(<SloEditPage />, config);
expect(mockAddError).toBeCalled();
});
});
render(<SloEditPage />, config);
expect(mockAddError).toBeCalled();
});
});
});

View file

@ -9,25 +9,31 @@ import React from 'react';
import { useParams } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { ObservabilityAppServices } from '../../application/types';
import { paths } from '../../config';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useKibana } from '../../utils/kibana_react';
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useLicense } from '../../hooks/use_license';
import { SloEditForm } from './components/slo_edit_form';
import PageNotFound from '../404';
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
export function SloEditPage() {
const { http } = useKibana<ObservabilityAppServices>().services;
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { sloId } = useParams<{ sloId: string | undefined }>();
const { hasAtLeast } = useLicense();
const hasRightLicense = hasAtLeast('platinum');
useBreadcrumbs([
{
href: http.basePath.prepend(paths.observability.slos),
href: basePath.prepend(paths.observability.slos),
text: i18n.translate('xpack.observability.breadcrumbs.sloEditLinkText', {
defaultMessage: 'SLOs',
}),
@ -40,6 +46,10 @@ export function SloEditPage() {
return <PageNotFound />;
}
if (hasRightLicense === false) {
navigateToUrl(basePath.prepend(paths.observability.slos));
}
if (loading) {
return null;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { SloListWelcomePrompt as Component } from './slo_list_welcome_prompt';
export default {
component: Component,
title: 'app/SLO/ListPage/SloListWelcomePrompt',
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = () => <Component />;
export const SloListWelcomePrompt = Template.bind({});

View file

@ -6,11 +6,21 @@
*/
import React from 'react';
import { EuiPageTemplate, EuiButton, EuiTitle, EuiLink, EuiImage } from '@elastic/eui';
import {
EuiPageTemplate,
EuiButton,
EuiTitle,
EuiLink,
EuiImage,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { paths } from '../../../config';
import { useKibana } from '../../../utils/kibana_react';
import { useLicense } from '../../../hooks/use_license';
import { paths } from '../../../config';
import illustration from './assets/illustration.svg';
export function SloListWelcomePrompt() {
@ -19,6 +29,10 @@ export function SloListWelcomePrompt() {
http: { basePath },
} = useKibana().services;
const { hasAtLeast } = useLicense();
const hasRightLicense = hasAtLeast('platinum');
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
@ -58,20 +72,92 @@ export function SloListWelcomePrompt() {
'Easily report the uptime and reliability of your services to stakeholders with real-time insights.',
})}
</p>
<p>
{i18n.translate('xpack.observability.slos.sloList.welcomePrompt.messageParagraph3', {
defaultMessage: 'To get started, create your first SLO.',
})}
</p>
<EuiSpacer size="s" />
</>
}
actions={
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
{i18n.translate('xpack.observability.slos.sloList.welcomePrompt.buttonLabel', {
defaultMessage: 'Create first SLO',
})}
</EuiButton>
<>
{hasRightLicense ? (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xxs">
<span>
{i18n.translate(
'xpack.observability.slos.sloList.welcomePrompt.getStartedMessage',
{
defaultMessage: 'To get started, create your first SLO.',
}
)}
</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<span>
<EuiButton fill color="primary" onClick={handleClickCreateSlo}>
{i18n.translate(
'xpack.observability.slos.sloList.welcomePrompt.buttonLabel',
{
defaultMessage: 'Create SLO',
}
)}
</EuiButton>
</span>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xxs">
<span>
{i18n.translate(
'xpack.observability.slos.sloList.welcomePrompt.needLicenseMessage',
{
defaultMessage:
'You need an Elastic Cloud subscription or Platinum license to use SLOs.',
}
)}
</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup direction="row">
<EuiFlexItem>
<EuiButton
fill
href="https://www.elastic.co/cloud/elasticsearch-service/signup"
target="_blank"
data-test-subj="slosPageWelcomePromptSignupForCloudButton"
>
{i18n.translate(
'xpack.observability.slos.sloList.welcomePrompt.signupForCloud',
{
defaultMessage: 'Sign up for Elastic Cloud',
}
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
href="https://www.elastic.co/subscriptions"
target="_blank"
data-test-subj="slosPageWelcomePromptSignupForLicenseButton"
>
{i18n.translate(
'xpack.observability.slos.sloList.welcomePrompt.signupForLicense',
{
defaultMessage: 'Sign up for license',
}
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
)}
</>
}
footer={
<>

View file

@ -8,29 +8,44 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { ConfigSchema } from '../../plugin';
import { Subset } from '../../typings';
import { kibanaStartMock } from '../../utils/kibana_react.mock';
import { render } from '../../utils/test_helper';
import { useKibana } from '../../utils/kibana_react';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { useLicense } from '../../hooks/use_license';
import { SlosPage } from '.';
import { emptySloList, sloList } from '../../data/slo';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import type { ConfigSchema } from '../../plugin';
import type { Subset } from '../../typings';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
}));
jest.mock('../../hooks/slo/use_fetch_slo_list');
jest.mock('../../utils/kibana_react');
jest.mock('../../hooks/use_breadcrumbs');
jest.mock('../../hooks/use_license');
jest.mock('../../hooks/slo/use_fetch_slo_list');
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
jest.mock('../../utils/kibana_react', () => ({
useKibana: jest.fn(() => mockUseKibanaReturnValue),
}));
const useKibanaMock = useKibana as jest.Mock;
const useLicenseMock = useLicense as jest.Mock;
const useFetchSloListMock = useFetchSloList as jest.Mock;
const mockNavigate = jest.fn();
const mockKibana = () => {
useKibanaMock.mockReturnValue({
services: {
application: { navigateToUrl: mockNavigate },
http: {
basePath: {
prepend: jest.fn(),
},
},
},
});
};
const config: Subset<ConfigSchema> = {
unsafe: {
slo: { enabled: true },
@ -40,38 +55,62 @@ const config: Subset<ConfigSchema> = {
describe('SLOs Page', () => {
beforeEach(() => {
jest.clearAllMocks();
mockKibana();
});
it('renders the not found page when the feature flag is not enabled', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
describe('when the feature flag is not enabled', () => {
it('renders the not found page ', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SlosPage />, { unsafe: { slo: { enabled: false } } });
render(<SlosPage />, { unsafe: { slo: { enabled: false } } });
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
});
});
describe('when the feature flag is enabled', () => {
it('renders nothing when the API is loading', async () => {
useFetchSloListMock.mockReturnValue({ loading: true, sloList: emptySloList });
describe('when the incorrect license is found', () => {
it('renders the welcome prompt with subscription buttons', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => false });
const { container } = render(<SlosPage />, config);
render(<SlosPage />, config);
expect(container).toBeEmptyDOMElement();
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForCloudButton')).toBeTruthy();
expect(screen.queryByTestId('slosPageWelcomePromptSignupForLicenseButton')).toBeTruthy();
});
});
it('renders the SLOs Welcome Prompt when the API has finished loading and there are no results', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
render(<SlosPage />, config);
describe('when the correct license is found', () => {
it('renders nothing when the API is loading', async () => {
useFetchSloListMock.mockReturnValue({ loading: true, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
});
const { container } = render(<SlosPage />, config);
it('renders the SLOs page when the API has finished loading and there are results', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList });
render(<SlosPage />, config);
expect(container).toBeEmptyDOMElement();
});
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
it('renders the SLOs Welcome Prompt when the API has finished loading and there are no results', async () => {
useFetchSloListMock.mockReturnValue({ loading: false, sloList: emptySloList });
useLicenseMock.mockReturnValue({ hasAtLeast: () => true });
render(<SlosPage />, config);
expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy();
});
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 });
render(<SlosPage />, config);
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
});
});
});
});

View file

@ -9,17 +9,17 @@ import React from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ObservabilityAppServices } from '../../application/types';
import { paths } from '../../config';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useKibana } from '../../utils/kibana_react';
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
import { SLOS_BREADCRUMB_TEXT, SLOS_PAGE_TITLE } from './translations';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useLicense } from '../../hooks/use_license';
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { SloList } from './components/slo_list';
import { SloListWelcomePrompt } from './components/slo_list_welcome_prompt';
import PageNotFound from '../404';
import { paths } from '../../config';
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
import type { ObservabilityAppServices } from '../../application/types';
export function SlosPage() {
const {
@ -28,6 +28,8 @@ export function SlosPage() {
} = useKibana<ObservabilityAppServices>().services;
const { ObservabilityPageTemplate, config } = usePluginContext();
const { hasAtLeast } = useLicense();
const {
loading,
sloList: { total },
@ -36,7 +38,9 @@ export function SlosPage() {
useBreadcrumbs([
{
href: basePath.prepend(paths.observability.slos),
text: SLOS_BREADCRUMB_TEXT,
text: i18n.translate('xpack.observability.breadcrumbs.slosLinkText', {
defaultMessage: 'SLOs',
}),
},
]);
@ -52,14 +56,16 @@ export function SlosPage() {
return null;
}
if (total === 0) {
if (total === 0 || !hasAtLeast('platinum')) {
return <SloListWelcomePrompt />;
}
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: SLOS_PAGE_TITLE,
pageTitle: i18n.translate('xpack.observability.slosPageTitle', {
defaultMessage: 'SLOs',
}),
rightSideItems: [
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
{i18n.translate('xpack.observability.slos.sloList.pageHeader.createNewButtonLabel', {

View file

@ -1,16 +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 { i18n } from '@kbn/i18n';
export const SLOS_PAGE_TITLE = i18n.translate('xpack.observability.slosPageTitle', {
defaultMessage: 'SLOs',
});
export const SLOS_BREADCRUMB_TEXT = i18n.translate('xpack.observability.breadcrumbs.slosLinkText', {
defaultMessage: 'SLOs',
});

View file

@ -40,6 +40,7 @@ import {
import { SecurityPluginStart } from '@kbn/security-plugin/public';
import { GuidedOnboardingPluginStart } from '@kbn/guided-onboarding-plugin/public';
import { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { RuleDetailsLocatorDefinition } from './locators/rule_details';
import { observabilityAppId, observabilityFeatureId, casesPath } from '../common';
import { createLazyObservabilityPageTemplate } from './components/shared';
@ -89,21 +90,22 @@ export interface ObservabilityPublicPluginsSetup {
}
export interface ObservabilityPublicPluginsStart {
usageCollection: UsageCollectionSetup;
actionTypeRegistry: ActionTypeRegistryContract;
cases: CasesUiStart;
embeddable: EmbeddableStart;
home?: HomePublicPluginStart;
share: SharePluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
lens: LensPublicStart;
discover: DiscoverStart;
ruleTypeRegistry: RuleTypeRegistryContract;
actionTypeRegistry: ActionTypeRegistryContract;
security: SecurityPluginStart;
embeddable: EmbeddableStart;
guidedOnboarding: GuidedOnboardingPluginStart;
lens: LensPublicStart;
licensing: LicensingPluginStart;
ruleTypeRegistry: RuleTypeRegistryContract;
security: SecurityPluginStart;
share: SharePluginStart;
spaces: SpacesPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
usageCollection: UsageCollectionSetup;
home?: HomePublicPluginStart;
}
export type ObservabilityPublicStart = ReturnType<Plugin['start']>;

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { badRequest } from '@hapi/boom';
import {
createSLOParamsSchema,
deleteSLOParamsSchema,
@ -29,8 +30,9 @@ import {
KQLCustomTransformGenerator,
TransformGenerator,
} from '../../services/slo/transform_generators';
import { IndicatorTypes } from '../../domain/models';
import { createObservabilityServerRoute } from '../create_observability_server_route';
import type { IndicatorTypes } from '../../domain/models';
import type { ObservabilityRequestHandlerContext } from '../../types';
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
@ -38,6 +40,10 @@ const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
'sli.kql.custom': new KQLCustomTransformGenerator(),
};
const isLicenseAtLeastPlatinum = async (context: ObservabilityRequestHandlerContext) => {
return (await context.licensing).license.hasAtLeast('platinum');
};
const createSLORoute = createObservabilityServerRoute({
endpoint: 'POST /api/observability/slos',
options: {
@ -45,6 +51,10 @@ const createSLORoute = createObservabilityServerRoute({
},
params: createSLOParamsSchema,
handler: async ({ context, params, logger }) => {
if (!isLicenseAtLeastPlatinum(context)) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
@ -66,6 +76,10 @@ const updateSLORoute = createObservabilityServerRoute({
},
params: updateSLOParamsSchema,
handler: async ({ context, params, logger }) => {
if (!isLicenseAtLeastPlatinum(context)) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
@ -86,6 +100,10 @@ const deleteSLORoute = createObservabilityServerRoute({
},
params: deleteSLOParamsSchema,
handler: async ({ context, params, logger }) => {
if (!isLicenseAtLeastPlatinum(context)) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
@ -105,6 +123,10 @@ const getSLORoute = createObservabilityServerRoute({
},
params: getSLOParamsSchema,
handler: async ({ context, params }) => {
if (!isLicenseAtLeastPlatinum(context)) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const repository = new KibanaSavedObjectsSLORepository(soClient);
@ -124,6 +146,10 @@ const findSLORoute = createObservabilityServerRoute({
},
params: findSLOParamsSchema,
handler: async ({ context, params }) => {
if (!isLicenseAtLeastPlatinum(context)) {
throw badRequest('Platinum license or higher is needed to make use of this feature.');
}
const soClient = (await context.core).savedObjects.client;
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const repository = new KibanaSavedObjectsSLORepository(soClient);

View file

@ -67,7 +67,6 @@
"@kbn/share-plugin",
"@kbn/core-notifications-browser",
"@kbn/slo-schema",
"@kbn/core-http-server-internal",
],
"exclude": [
"target/**/*",