From e75325653c88553454270bf412ddbed700fc022b Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Fri, 12 May 2023 17:45:16 +0200 Subject: [PATCH] Add SloGlobalDiagnosis check to SLO List and SLO Create pages (#157488) Co-authored-by: Kevin Delemme Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/convert_error_for_use_in_toast.ts | 15 ++++ .../hooks/slo/use_fetch_global_diagnosis.ts | 86 +++++++++++++++++++ .../public/pages/slo_edit/slo_edit.tsx | 8 +- .../pages/slos_welcome/slos_welcome.test.tsx | 34 ++++++++ .../pages/slos_welcome/slos_welcome.tsx | 9 +- .../observability/server/routes/slo/route.ts | 12 ++- .../server/services/slo/get_diagnosis.ts | 69 ++++++++------- 7 files changed, 196 insertions(+), 37 deletions(-) create mode 100644 x-pack/plugins/observability/public/hooks/slo/helpers/convert_error_for_use_in_toast.ts create mode 100644 x-pack/plugins/observability/public/hooks/slo/use_fetch_global_diagnosis.ts diff --git a/x-pack/plugins/observability/public/hooks/slo/helpers/convert_error_for_use_in_toast.ts b/x-pack/plugins/observability/public/hooks/slo/helpers/convert_error_for_use_in_toast.ts new file mode 100644 index 0000000000000..aa24fbf229daa --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/slo/helpers/convert_error_for_use_in_toast.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function convertErrorForUseInToast(error: Error) { + const newError = { + ...error, + message: Object(error).body.message, + }; + + return newError; +} diff --git a/x-pack/plugins/observability/public/hooks/slo/use_fetch_global_diagnosis.ts b/x-pack/plugins/observability/public/hooks/slo/use_fetch_global_diagnosis.ts new file mode 100644 index 0000000000000..c265623a10b19 --- /dev/null +++ b/x-pack/plugins/observability/public/hooks/slo/use_fetch_global_diagnosis.ts @@ -0,0 +1,86 @@ +/* + * 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 { + QueryObserverResult, + RefetchOptions, + RefetchQueryFilters, + useQuery, +} from '@tanstack/react-query'; +import { i18n } from '@kbn/i18n'; +import type { PublicLicenseJSON } from '@kbn/licensing-plugin/public'; +import type { SecurityGetUserPrivilegesResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { useKibana } from '../../utils/kibana_react'; +import { convertErrorForUseInToast } from './helpers/convert_error_for_use_in_toast'; + +interface SloGlobalDiagnosisResponse { + licenseAndFeatures: PublicLicenseJSON; + userPrivileges: SecurityGetUserPrivilegesResponse; + sloResources: { + [x: string]: 'OK' | 'NOT_OK'; + }; +} + +export interface UseFetchSloGlobalDiagnoseResponse { + isInitialLoading: boolean; + isLoading: boolean; + isRefetching: boolean; + isSuccess: boolean; + isError: boolean; + globalSloDiagnosis: SloGlobalDiagnosisResponse | undefined; + refetch: ( + options?: (RefetchOptions & RefetchQueryFilters) | undefined + ) => Promise>; +} + +export function useFetchSloGlobalDiagnosis(): UseFetchSloGlobalDiagnoseResponse { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery( + { + queryKey: ['fetchSloGlobalDiagnosis'], + queryFn: async ({ signal }) => { + try { + const response = await http.get( + '/internal/observability/slos/_diagnosis', + { + query: {}, + signal, + } + ); + + return response; + } catch (error) { + throw convertErrorForUseInToast(error); + } + }, + keepPreviousData: true, + refetchOnWindowFocus: false, + retry: false, + onError: (error: Error) => { + toasts.addError(error, { + title: i18n.translate('xpack.observability.slo.globalDiagnosis.errorNotification', { + defaultMessage: 'You do not have the right permissions to use this feature.', + }), + }); + }, + } + ); + + return { + globalSloDiagnosis: data, + isLoading, + isInitialLoading, + isRefetching, + isSuccess, + isError, + refetch, + }; +} diff --git a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.tsx b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.tsx index 0fb03f0d4e128..ed3422c2ceb5a 100644 --- a/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.tsx +++ b/x-pack/plugins/observability/public/pages/slo_edit/slo_edit.tsx @@ -15,9 +15,10 @@ import { usePluginContext } from '../../hooks/use_plugin_context'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details'; import { useLicense } from '../../hooks/use_license'; -import { SloEditForm } from './components/slo_edit_form'; -import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button'; import { useCapabilities } from '../../hooks/slo/use_capabilities'; +import { useFetchSloGlobalDiagnosis } from '../../hooks/slo/use_fetch_global_diagnosis'; +import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button'; +import { SloEditForm } from './components/slo_edit_form'; export function SloEditPage() { const { @@ -25,6 +26,7 @@ export function SloEditPage() { http: { basePath }, } = useKibana().services; const { hasWriteCapabilities } = useCapabilities(); + const { isError: hasErrorInGlobalDiagnosis } = useFetchSloGlobalDiagnosis(); const { ObservabilityPageTemplate } = usePluginContext(); const { sloId } = useParams<{ sloId: string | undefined }>(); @@ -43,7 +45,7 @@ export function SloEditPage() { const { slo, isInitialLoading } = useFetchSloDetails({ sloId }); - if (hasRightLicense === false || !hasWriteCapabilities) { + if (hasRightLicense === false || !hasWriteCapabilities || hasErrorInGlobalDiagnosis) { navigateToUrl(basePath.prepend(paths.observability.slos)); } diff --git a/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.test.tsx b/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.test.tsx index ec342555014bc..5b64c0ac5b5e5 100644 --- a/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.test.tsx +++ b/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.test.tsx @@ -11,6 +11,7 @@ import { screen, waitFor } 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 { useFetchSloGlobalDiagnosis } from '../../hooks/slo/use_fetch_global_diagnosis'; import { useLicense } from '../../hooks/use_license'; import { SlosWelcomePage } from './slos_welcome'; import { emptySloList, sloList } from '../../data/slo/slo'; @@ -22,11 +23,13 @@ jest.mock('../../hooks/use_breadcrumbs'); jest.mock('../../hooks/use_license'); jest.mock('../../hooks/slo/use_fetch_slo_list'); jest.mock('../../hooks/slo/use_capabilities'); +jest.mock('../../hooks/slo/use_fetch_global_diagnosis'); const useKibanaMock = useKibana as jest.Mock; const useLicenseMock = useLicense as jest.Mock; const useFetchSloListMock = useFetchSloList as jest.Mock; const useCapabilitiesMock = useCapabilities as jest.Mock; +const useGlobalDiagnosisMock = useFetchSloGlobalDiagnosis as jest.Mock; const mockNavigate = jest.fn(); @@ -54,6 +57,9 @@ describe('SLOs Welcome Page', () => { it('renders the welcome message with subscription buttons', async () => { useFetchSloListMock.mockReturnValue({ isLoading: false, sloList: emptySloList }); useLicenseMock.mockReturnValue({ hasAtLeast: () => false }); + useGlobalDiagnosisMock.mockReturnValue({ + isError: false, + }); render(); @@ -66,6 +72,9 @@ describe('SLOs Welcome Page', () => { describe('when the correct license is found', () => { beforeEach(() => { useLicenseMock.mockReturnValue({ hasAtLeast: () => true }); + useGlobalDiagnosisMock.mockReturnValue({ + isError: false, + }); }); describe('when loading is done and no results are found', () => { @@ -79,6 +88,24 @@ describe('SLOs Welcome Page', () => { hasReadCapabilities: true, }); + render(); + + expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy(); + + const createNewSloButton = screen.queryByTestId('o11ySloListWelcomePromptCreateSloButton'); + + expect(createNewSloButton).toBeDisabled(); + }); + + it('disables the create slo button when no cluster permissions capabilities', async () => { + useCapabilitiesMock.mockReturnValue({ + hasWriteCapabilities: true, + hasReadCapabilities: true, + }); + useGlobalDiagnosisMock.mockReturnValue({ + isError: true, + }); + render(); expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy(); @@ -87,6 +114,10 @@ describe('SLOs Welcome Page', () => { }); it('should display the welcome message with a Create new SLO button which should navigate to the SLO Creation page', async () => { + useGlobalDiagnosisMock.mockReturnValue({ + isError: false, + }); + render(); expect(screen.queryByTestId('slosPageWelcomePrompt')).toBeTruthy(); @@ -103,6 +134,9 @@ describe('SLOs Welcome Page', () => { describe('when loading is done and results are found', () => { beforeEach(() => { useFetchSloListMock.mockReturnValue({ isLoading: false, sloList }); + useGlobalDiagnosisMock.mockReturnValue({ + isError: false, + }); }); it('should navigate to the SLO List page', async () => { diff --git a/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.tsx b/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.tsx index 71d71413bf255..d43d431984823 100644 --- a/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.tsx +++ b/x-pack/plugins/observability/public/pages/slos_welcome/slos_welcome.tsx @@ -21,10 +21,11 @@ import { i18n } from '@kbn/i18n'; import { useKibana } from '../../utils/kibana_react'; import { useLicense } from '../../hooks/use_license'; import { usePluginContext } from '../../hooks/use_plugin_context'; +import { useCapabilities } from '../../hooks/slo/use_capabilities'; import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list'; import { paths } from '../../config/paths'; import illustration from './assets/illustration.svg'; -import { useCapabilities } from '../../hooks/slo/use_capabilities'; +import { useFetchSloGlobalDiagnosis } from '../../hooks/slo/use_fetch_global_diagnosis'; export function SlosWelcomePage() { const { @@ -32,6 +33,7 @@ export function SlosWelcomePage() { http: { basePath }, } = useKibana().services; const { hasWriteCapabilities } = useCapabilities(); + const { isError: hasErrorInGlobalDiagnosis } = useFetchSloGlobalDiagnosis(); const { ObservabilityPageTemplate } = usePluginContext(); const { hasAtLeast } = useLicense(); @@ -44,7 +46,8 @@ export function SlosWelcomePage() { navigateToUrl(basePath.prepend(paths.observability.sloCreate)); }; - const hasSlosAndHasPermissions = total > 0 && hasAtLeast('platinum') === true; + const hasSlosAndHasPermissions = + total > 0 && hasAtLeast('platinum') === true && !hasErrorInGlobalDiagnosis; useEffect(() => { if (hasSlosAndHasPermissions) { @@ -110,7 +113,7 @@ export function SlosWelcomePage() { fill color="primary" onClick={handleClickCreateSlo} - disabled={!hasWriteCapabilities} + disabled={!hasWriteCapabilities || hasErrorInGlobalDiagnosis} > {i18n.translate('xpack.observability.slo.sloList.welcomePrompt.buttonLabel', { defaultMessage: 'Create SLO', diff --git a/x-pack/plugins/observability/server/routes/slo/route.ts b/x-pack/plugins/observability/server/routes/slo/route.ts index 52174f733994f..87298e8433dd3 100644 --- a/x-pack/plugins/observability/server/routes/slo/route.ts +++ b/x-pack/plugins/observability/server/routes/slo/route.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { badRequest } from '@hapi/boom'; +import { badRequest, forbidden, failedDependency } from '@hapi/boom'; import { createSLOParamsSchema, deleteSLOParamsSchema, @@ -275,7 +275,15 @@ const getDiagnosisRoute = createObservabilityServerRoute({ const esClient = (await context.core).elasticsearch.client.asCurrentUser; const licensing = await context.licensing; - return getGlobalDiagnosis(esClient, licensing); + try { + const response = await getGlobalDiagnosis(esClient, licensing); + return response; + } catch (error) { + if (error.cause.statusCode === 403) { + throw forbidden('Insufficient Elasticsearch cluster permissions to access feature.'); + } + throw failedDependency(error); + } }, }); diff --git a/x-pack/plugins/observability/server/services/slo/get_diagnosis.ts b/x-pack/plugins/observability/server/services/slo/get_diagnosis.ts index 9b2aec2feab22..a70b65ab89502 100644 --- a/x-pack/plugins/observability/server/services/slo/get_diagnosis.ts +++ b/x-pack/plugins/observability/server/services/slo/get_diagnosis.ts @@ -26,15 +26,19 @@ export async function getGlobalDiagnosis( esClient: ElasticsearchClient, licensing: LicensingApiRequestHandlerContext ) { - const licenseInfo = licensing.license.toJSON(); - const userPrivileges = await esClient.security.getUserPrivileges(); - const sloResources = await getSloResourcesDiagnosis(esClient); - - return { - licenseAndFeatures: licenseInfo, - userPrivileges, - sloResources, - }; + try { + const licenseInfo = licensing.license.toJSON(); + const userPrivileges = await esClient.security.getUserPrivileges(); + const sloResources = await getSloResourcesDiagnosis(esClient); + + return { + licenseAndFeatures: licenseInfo, + userPrivileges, + sloResources, + }; + } catch (error) { + throw error; + } } export async function getSloDiagnosis( @@ -79,29 +83,36 @@ export async function getSloDiagnosis( } async function getSloResourcesDiagnosis(esClient: ElasticsearchClient) { - const indexTemplateExists = await esClient.indices.existsIndexTemplate({ - name: SLO_INDEX_TEMPLATE_NAME, - }); + try { + const indexTemplateExists = await esClient.indices.existsIndexTemplate({ + name: SLO_INDEX_TEMPLATE_NAME, + }); - const mappingsTemplateExists = await esClient.cluster.existsComponentTemplate({ - name: SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME, - }); + const mappingsTemplateExists = await esClient.cluster.existsComponentTemplate({ + name: SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME, + }); - const settingsTemplateExists = await esClient.cluster.existsComponentTemplate({ - name: SLO_COMPONENT_TEMPLATE_SETTINGS_NAME, - }); + const settingsTemplateExists = await esClient.cluster.existsComponentTemplate({ + name: SLO_COMPONENT_TEMPLATE_SETTINGS_NAME, + }); - let ingestPipelineExists = true; - try { - await esClient.ingest.getPipeline({ id: SLO_INGEST_PIPELINE_NAME }); + let ingestPipelineExists = true; + try { + await esClient.ingest.getPipeline({ id: SLO_INGEST_PIPELINE_NAME }); + } catch (err) { + ingestPipelineExists = false; + throw err; + } + + return { + [SLO_INDEX_TEMPLATE_NAME]: indexTemplateExists ? OK : NOT_OK, + [SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME]: mappingsTemplateExists ? OK : NOT_OK, + [SLO_COMPONENT_TEMPLATE_SETTINGS_NAME]: settingsTemplateExists ? OK : NOT_OK, + [SLO_INGEST_PIPELINE_NAME]: ingestPipelineExists ? OK : NOT_OK, + }; } catch (err) { - ingestPipelineExists = false; + if (err.meta.statusCode === 403) { + throw new Error('Insufficient permissions to access Elasticsearch Cluster', { cause: err }); + } } - - return { - [SLO_INDEX_TEMPLATE_NAME]: indexTemplateExists ? OK : NOT_OK, - [SLO_COMPONENT_TEMPLATE_MAPPINGS_NAME]: mappingsTemplateExists ? OK : NOT_OK, - [SLO_COMPONENT_TEMPLATE_SETTINGS_NAME]: settingsTemplateExists ? OK : NOT_OK, - [SLO_INGEST_PIPELINE_NAME]: ingestPipelineExists ? OK : NOT_OK, - }; }