diff --git a/.buildkite/pipelines/code_coverage/daily.yml b/.buildkite/pipelines/code_coverage/daily.yml index a2b501a39b088b..c9bb675ab40cf9 100644 --- a/.buildkite/pipelines/code_coverage/daily.yml +++ b/.buildkite/pipelines/code_coverage/daily.yml @@ -13,7 +13,8 @@ steps: queue: kibana-default env: FTR_CONFIGS_DEPS: '' - LIMIT_CONFIG_TYPE: 'unit,functional,integration' +# LIMIT_CONFIG_TYPE: 'unit,functional,integration' + LIMIT_CONFIG_TYPE: 'unit,integration' JEST_UNIT_SCRIPT: '.buildkite/scripts/steps/code_coverage/jest.sh' JEST_INTEGRATION_SCRIPT: '.buildkite/scripts/steps/code_coverage/jest_integration.sh' FTR_CONFIGS_SCRIPT: '.buildkite/scripts/steps/code_coverage/ftr_configs.sh' @@ -25,6 +26,6 @@ steps: depends_on: - jest - jest-integration - - ftr-configs +# - ftr-configs timeout_in_minutes: 30 key: ingest diff --git a/.buildkite/scripts/steps/code_coverage/ingest.sh b/.buildkite/scripts/steps/code_coverage/ingest.sh index a2f1b572252a5f..a39097f706262a 100755 --- a/.buildkite/scripts/steps/code_coverage/ingest.sh +++ b/.buildkite/scripts/steps/code_coverage/ingest.sh @@ -27,7 +27,7 @@ echo "--- Upload new git sha" echo "--- Download coverage artifacts" buildkite-agent artifact download target/kibana-coverage/jest/* . -buildkite-agent artifact download target/kibana-coverage/functional/* . +#buildkite-agent artifact download target/kibana-coverage/functional/* . buildkite-agent artifact download target/ran_files/* . ls -l target/ran_files/* || echo "### No ran-files found" @@ -42,20 +42,20 @@ echo "--- Jest: Reset file paths prefix, merge coverage files, and generate the replacePaths "$KIBANA_DIR/target/kibana-coverage/jest" "CC_REPLACEMENT_ANCHOR" "$KIBANA_DIR" yarn nyc report --nycrc-path src/dev/code_coverage/nyc_config/nyc.jest.config.js -echo "--- Functional: Reset file paths prefix, merge coverage files, and generate the final combined report" +#echo "--- Functional: Reset file paths prefix, merge coverage files, and generate the final combined report" # Functional: Reset file paths prefix to Kibana Dir of final worker -set +e -sed -ie "s|CC_REPLACEMENT_ANCHOR|${KIBANA_DIR}|g" target/kibana-coverage/functional/*.json -echo "--- Begin Split and Merge for Functional" -splitCoverage target/kibana-coverage/functional -splitMerge -set -e +#set +e +#sed -ie "s|CC_REPLACEMENT_ANCHOR|${KIBANA_DIR}|g" target/kibana-coverage/functional/*.json +#echo "--- Begin Split and Merge for Functional" +#splitCoverage target/kibana-coverage/functional +#splitMerge +#set -e echo "--- Archive and upload combined reports" collectAndUpload target/kibana-coverage/jest/kibana-jest-coverage.tar.gz \ target/kibana-coverage/jest-combined -collectAndUpload target/kibana-coverage/functional/kibana-functional-coverage.tar.gz \ - target/kibana-coverage/functional-combined +#collectAndUpload target/kibana-coverage/functional/kibana-functional-coverage.tar.gz \ +# target/kibana-coverage/functional-combined echo "--- Upload coverage static site" .buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh diff --git a/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh b/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh index c1df5ff4b39cf4..de006352d0b09e 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/ingestData.sh @@ -38,14 +38,20 @@ echo "### Generate Team Assignments" CI_STATS_DISABLED=true node scripts/generate_team_assignments.js \ --verbose --src '.github/CODEOWNERS' --dest $TEAM_ASSIGN_PATH -for x in functional jest; do - echo "### Ingesting coverage for ${x}" - COVERAGE_SUMMARY_FILE="target/kibana-coverage/${x}-combined/coverage-summary.json" - - CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ - --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH & -done -wait +#for x in functional jest; do +# echo "### Ingesting coverage for ${x}" +# COVERAGE_SUMMARY_FILE="target/kibana-coverage/${x}-combined/coverage-summary.json" +# +# CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ +# --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH & +#done +#wait + +echo "### Ingesting coverage for JEST" +COVERAGE_SUMMARY_FILE="target/kibana-coverage/jest-combined/coverage-summary.json" + +CI_STATS_DISABLED=true node scripts/ingest_coverage.js --path ${COVERAGE_SUMMARY_FILE} \ + --vcsInfoPath ./VCS_INFO.txt --teamAssignmentsPath $TEAM_ASSIGN_PATH echo "--- Ingesting Code Coverage - Complete" echo "" diff --git a/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh b/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh index f982ee5c581ce5..0b6d0ce8ea105b 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/prokLinks.sh @@ -4,7 +4,6 @@ set -euo pipefail cat << EOF > src/dev/code_coverage/www/index_partial_2.html Latest Jest - Latest FTR @@ -26,4 +25,4 @@ cat << EOF > src/dev/code_coverage/www/index_partial_2.html EOF cat src/dev/code_coverage/www/index_partial.html > src/dev/code_coverage/www/index.html -cat src/dev/code_coverage/www/index_partial_2.html >> src/dev/code_coverage/www/index.html \ No newline at end of file +cat src/dev/code_coverage/www/index_partial_2.html >> src/dev/code_coverage/www/index.html diff --git a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh index 4d3fb92cf2b437..dcb0b03b16d7cf 100755 --- a/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh +++ b/.buildkite/scripts/steps/code_coverage/reporting/uploadStaticSite.sh @@ -11,8 +11,10 @@ for x in 'src/dev/code_coverage/www/index.html' 'src/dev/code_coverage/www/404.h gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefix} done -gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +#gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +# +#for x in 'target/kibana-coverage/functional-combined' 'target/kibana-coverage/jest-combined'; do +# gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} +#done -for x in 'target/kibana-coverage/functional-combined' 'target/kibana-coverage/jest-combined'; do - gsutil -m -q cp -r -a public-read -z js,css,html ${x} ${uploadPrefixWithTimeStamp} -done +gsutil -m -q cp -r -a public-read -z js,css,html 'target/kibana-coverage/jest-combined' ${uploadPrefixWithTimeStamp} diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index 06cb30a74fe976..f2d156c4af6810 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -154,12 +154,12 @@ describe('ExpressionLoader', () => { it('throttles partial results', async () => { testScheduler.run(({ cold, expectObservable }) => { const expressionLoader = new ExpressionLoader(element, 'var foo', { - variables: { foo: cold('a 5ms b 5ms c 10ms d', { a: 1, b: 2, c: 3, d: 4 }) }, + variables: { foo: cold('a 5ms b 5ms c 10ms (d|)', { a: 1, b: 2, c: 3, d: 4 }) }, partial: true, throttle: 20, }); - expectObservable(expressionLoader.data$).toBe('a 19ms c 19ms d', { + expectObservable(expressionLoader.data$).toBe('a 19ms c 2ms d', { a: expect.objectContaining({ result: 1 }), c: expect.objectContaining({ result: 3 }), d: expect.objectContaining({ result: 4 }), diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 7f7a96fde6f1ae..afbde2ab3043a2 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import { BehaviorSubject, Observable, Subject, Subscription, asyncScheduler, identity } from 'rxjs'; -import { filter, map, delay, shareReplay, throttleTime } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subject, Subscription, identity, timer } from 'rxjs'; +import { delay, filter, finalize, map, shareReplay, takeWhile } from 'rxjs/operators'; import { defaults } from 'lodash'; import { SerializableRecord, UnwrapObservable } from '@kbn/utility-types'; import { Adapters } from '@kbn/inspector-plugin/public'; @@ -20,6 +20,61 @@ import { getExpressionsService } from './services'; type Data = unknown; +/** + * RxJS' `throttle` operator does not emit the last value immediately when the source observable is completed. + * Instead, it waits for the next throttle period to emit that. + * It might cause delays until we get the final value, even though it is already there. + * @see https://github.com/ReactiveX/rxjs/blob/master/src/internal/operators/throttle.ts#L121 + */ +function throttle(timeout: number) { + return (source: Observable): Observable => + new Observable((subscriber) => { + let latest: T | undefined; + let hasValue = false; + + const emit = () => { + if (hasValue) { + subscriber.next(latest); + hasValue = false; + latest = undefined; + } + }; + + let throttled: Subscription | undefined; + const timer$ = timer(0, timeout).pipe( + takeWhile(() => hasValue), + finalize(() => { + subscriber.remove(throttled!); + throttled = undefined; + }) + ); + + subscriber.add( + source.subscribe({ + next: (value) => { + latest = value; + hasValue = true; + + if (!throttled) { + throttled = timer$.subscribe(emit); + subscriber.add(throttled); + } + }, + error: (error) => subscriber.error(error), + complete: () => { + emit(); + subscriber.complete(); + }, + }) + ); + + subscriber.add(() => { + hasValue = false; + latest = undefined; + }); + }); +} + export class ExpressionLoader { data$: ReturnType; update$: ExpressionRenderHandler['update$']; @@ -151,9 +206,7 @@ export class ExpressionLoader { .pipe( delay(0), // delaying until the next tick since we execute the expression in the constructor filter(({ partial }) => params.partial || !partial), - params.partial && params.throttle - ? throttleTime(params.throttle, asyncScheduler, { leading: true, trailing: true }) - : identity + params.partial && params.throttle ? throttle(params.throttle) : identity ) .subscribe((value) => this.dataSubject.next(value)); }; diff --git a/x-pack/plugins/apm/common/agent_configuration/all_option.ts b/x-pack/plugins/apm/common/agent_configuration/all_option.ts index 6737c8fa0c24c6..6f5604989bba31 100644 --- a/x-pack/plugins/apm/common/agent_configuration/all_option.ts +++ b/x-pack/plugins/apm/common/agent_configuration/all_option.ts @@ -6,7 +6,6 @@ */ import { i18n } from '@kbn/i18n'; - export const ALL_OPTION_VALUE = 'ALL_OPTION_VALUE'; // human-readable label for service and environment. The "All" option should be translated. @@ -24,3 +23,8 @@ export function getOptionLabel(value: string | undefined) { export function omitAllOption(value?: string) { return value === ALL_OPTION_VALUE ? undefined : value; } + +export const ALL_OPTION = { + value: ALL_OPTION_VALUE, + label: getOptionLabel(ALL_OPTION_VALUE), +}; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts index 1314b757c66fb5..4661ea67ae2ab4 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/power_user/settings/agent_configurations.spec.ts @@ -101,4 +101,29 @@ describe('Agent configuration', () => { cy.wait('@serviceEnvironmentApi'); cy.contains('production'); }); + it('displays All label when selecting all option', () => { + cy.intercept( + 'GET', + '/api/apm/settings/agent-configuration/environments' + ).as('serviceEnvironmentApi'); + cy.contains('Create configuration').click(); + cy.get('[data-test-subj="serviceNameComboBox"]') + .click() + .type('All') + .type('{enter}'); + cy.contains('All').realClick(); + cy.wait('@serviceEnvironmentApi'); + + cy.get('[data-test-subj="serviceEnviromentComboBox"]') + .click({ force: true }) + .type('All'); + + cy.get('mark').contains('All').click(); + cy.contains('Next step').click(); + cy.contains('Service name All'); + cy.contains('Environment All'); + cy.contains('Edit').click(); + cy.wait('@serviceEnvironmentApi'); + cy.contains('All'); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx index 2fb481b26be2ef..41e22ac840bc1e 100644 --- a/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/agent_configurations/agent_configuration_create_edit/service_page/form_row_suggestions_select.tsx @@ -9,7 +9,10 @@ import { EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { SuggestionsSelect } from '../../../../../shared/suggestions_select'; -import { ENVIRONMENT_ALL } from '../../../../../../../common/environment_filter_values'; +import { + getOptionLabel, + ALL_OPTION, +} from '../../../../../../../common/agent_configuration/all_option'; interface Props { title: string; @@ -40,8 +43,8 @@ export function FormRowSuggestionsSelect({ > ; @@ -132,12 +130,6 @@ describe('AllCasesListGeneric', () => { updateBulkStatus, }; - const defaultActionLicense = { - data: null, - isLoading: false, - isError: false, - }; - const defaultColumnArgs = { caseDetailsNavigation: { href: jest.fn(), @@ -166,7 +158,6 @@ describe('AllCasesListGeneric', () => { useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); useGetCasesMetricsMock.mockReturnValue(defaultCasesMetrics); - useGetActionLicenseMock.mockReturnValue(defaultActionLicense); useGetTagsMock.mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); useGetReportersMock.mockReturnValue({ reporters: ['casetester'], diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index 716d31ac9698da..f79d90bec1fb54 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -21,7 +21,11 @@ import { useFetchCases } from '../../containers/use_get_cases'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); -jest.mock('../../containers/use_get_action_license'); +jest.mock('../../containers/use_get_action_license', () => { + return { + useGetActionLicense: jest.fn(), + }; +}); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/api'); jest.mock('../../containers/use_get_cases'); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 4d00426339c035..75e44c307578cb 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { act, waitFor } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { act } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; import { mount } from 'enzyme'; import React from 'react'; @@ -15,23 +16,29 @@ import '../../common/mock/match_media'; import { useCaseViewNavigation, useUrlParams } from '../../common/navigation/hooks'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { + alertComment, + basicCase, basicCaseClosed, basicCaseMetrics, caseUserActions, connectorsMock, getAlertUserAction, } from '../../containers/mock'; +import { Case } from '../../containers/types'; +import { useGetCase, UseGetCase } from '../../containers/use_get_case'; import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; +import { useGetTags } from '../../containers/use_get_tags'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; import { useUpdateCase } from '../../containers/use_update_case'; import { CaseViewPage } from './case_view_page'; -import { caseData, caseViewProps } from './index.test'; -import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; +import { CaseViewPageProps, CaseViewProps, CASE_VIEW_PAGE_TABS } from './types'; +jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_metrics'); jest.mock('../../containers/use_get_case_user_actions'); +jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_case'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/use_post_push_to_service'); @@ -39,6 +46,7 @@ jest.mock('../user_actions/timestamp'); jest.mock('../../common/navigation/hooks'); jest.mock('../../common/hooks'); +const useFetchCaseMock = useGetCase as jest.Mock; const useUrlParamsMock = useUrlParams as jest.Mock; const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; @@ -46,6 +54,86 @@ const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; const useGetConnectorsMock = useGetConnectors as jest.Mock; const usePostPushToServiceMock = usePostPushToService as jest.Mock; const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; +const useGetTagsMock = useGetTags as jest.Mock; + +const alertsHit = [ + { + _id: 'alert-id-1', + _index: 'alert-index-1', + _source: { + signal: { + rule: { + id: 'rule-id-1', + name: 'Awesome rule', + }, + }, + }, + }, + { + _id: 'alert-id-2', + _index: 'alert-index-2', + _source: { + signal: { + rule: { + id: 'rule-id-2', + name: 'Awesome rule 2', + }, + }, + }, + }, +]; + +export const caseViewProps: CaseViewProps = { + onComponentInitialized: jest.fn(), + actionsNavigation: { + href: jest.fn(), + onClick: jest.fn(), + }, + ruleDetailsNavigation: { + href: jest.fn(), + onClick: jest.fn(), + }, + showAlertDetails: jest.fn(), + useFetchAlertData: () => [ + false, + { + 'alert-id-1': alertsHit[0], + 'alert-id-2': alertsHit[1], + }, + ], +}; + +export const caseData: Case = { + ...basicCase, + comments: [...basicCase.comments, alertComment], + connector: { + id: 'resilient-2', + name: 'Resilient', + type: ConnectorTypes.resilient, + fields: null, + }, +}; +const defaultGetCase = { + isLoading: false, + isError: false, + data: { + case: caseData, + outcome: 'exactMatch', + }, + refetch: jest.fn(), +}; + +const mockGetCase = (props: Partial = {}) => { + const data = { + ...defaultGetCase.data, + ...props.data, + }; + useFetchCaseMock.mockReturnValue({ + ...defaultGetCase, + ...props, + data, + }); +}; export const caseProps: CaseViewPageProps = { ...caseViewProps, @@ -96,12 +184,14 @@ describe('CaseViewPage', () => { }; beforeEach(() => { + mockGetCase(); jest.clearAllMocks(); useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState); useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics); useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions); usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); + useGetTagsMock.mockReturnValue({ data: [], isLoading: false }); }); it('should render CaseViewPage', async () => { @@ -326,14 +416,6 @@ describe('CaseViewPage', () => { }); it('should disable the push button when connector is invalid', async () => { - useGetCaseUserActionsMock.mockImplementation(() => ({ - ...defaultUseGetCaseUserActions, - data: { - ...defaultUseGetCaseUserActions.data, - hasDataToPush: true, - }, - })); - const wrapper = mount( ; +const useGetTagsMock = useGetTags as jest.Mock; const spacesUiApiMock = { redirectLegacyUrl: jest.fn().mockResolvedValue(undefined), @@ -184,6 +196,7 @@ describe('CaseView', () => { useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions); usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService }); useGetConnectorsMock.mockReturnValue({ data: connectorsMock, isLoading: false }); + useGetTagsMock.mockReturnValue({ data: [], isLoading: false }); useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi; }); diff --git a/x-pack/plugins/cases/public/components/case_view/use_on_refresh_case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/use_on_refresh_case_view_page.tsx index 9d2e606192c85a..a4c36deabef80e 100644 --- a/x-pack/plugins/cases/public/components/case_view/use_on_refresh_case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/use_on_refresh_case_view_page.tsx @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { useQueryClient } from 'react-query'; -import { CASE_VIEW_CACHE_KEY } from '../../containers/constants'; +import { CASE_TAGS_CACHE_KEY, CASE_VIEW_CACHE_KEY } from '../../containers/constants'; /** * Using react-query queryClient to invalidate all the @@ -21,5 +21,6 @@ export const useRefreshCaseViewPage = () => { const queryClient = useQueryClient(); return useCallback(() => { queryClient.invalidateQueries(CASE_VIEW_CACHE_KEY); + queryClient.invalidateQueries(CASE_TAGS_CACHE_KEY); }, [queryClient]); }; diff --git a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx index 615c2780599de2..15cfefd57ac57e 100644 --- a/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/cases/public/components/use_push_to_service/index.test.tsx @@ -19,7 +19,11 @@ import { CLOSED_CASE_PUSH_ERROR_ID } from './callout/types'; import * as i18n from './translations'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -jest.mock('../../containers/use_get_action_license'); +jest.mock('../../containers/use_get_action_license', () => { + return { + useGetActionLicense: jest.fn(), + }; +}); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../../containers/configure/api'); jest.mock('../../common/navigation/hooks'); diff --git a/x-pack/plugins/cases/public/containers/__mocks__/use_get_action_license.tsx b/x-pack/plugins/cases/public/containers/__mocks__/use_get_action_license.tsx new file mode 100644 index 00000000000000..e0e07300830cd4 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/__mocks__/use_get_action_license.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { actionLicenses } from '../mock'; + +export const useGetActionLicense = () => { + return { + data: actionLicenses[0], + isLoading: false, + isError: false, + isFetching: false, + }; +}; diff --git a/x-pack/plugins/cases/public/containers/use_get_tags.tsx b/x-pack/plugins/cases/public/containers/use_get_tags.tsx index 2b0e3c4387fcc9..3e18ac0a013152 100644 --- a/x-pack/plugins/cases/public/containers/use_get_tags.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_tags.tsx @@ -13,11 +13,12 @@ import { getTags } from './api'; import { CASE_TAGS_CACHE_KEY } from './constants'; import * as i18n from './translations'; -export const useGetTags = (cacheKey: string = '') => { +export const useGetTags = (cacheKey?: string) => { const toasts = useToasts(); const { owner } = useCasesContext(); + const key = [...(cacheKey ? [cacheKey] : []), CASE_TAGS_CACHE_KEY]; return useQuery( - [cacheKey, CASE_TAGS_CACHE_KEY], + key, () => { const abortCtrl = new AbortController(); return getTags(abortCtrl.signal, owner); diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_breadcrumbs.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_breadcrumbs.ts index 32ade5badafb00..89c77cf22ba641 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_breadcrumbs.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/use_csp_breadcrumbs.ts @@ -9,6 +9,8 @@ import type { ChromeBreadcrumb, CoreStart } from '@kbn/core/public'; import { useEffect } from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { type RouteProps, useRouteMatch, useHistory } from 'react-router-dom'; +import type { EuiBreadcrumb } from '@elastic/eui'; +import { string } from 'io-ts'; import type { CspNavigationItem } from './types'; import { CLOUD_POSTURE } from './translations'; @@ -27,7 +29,7 @@ const getClickableBreadcrumb = ( export const useCspBreadcrumbs = (breadcrumbs: CspNavigationItem[]) => { const { services: { - chrome: { setBreadcrumbs }, + chrome: { setBreadcrumbs, docTitle }, application: { getUrlForApp }, }, } = useKibana(); @@ -49,15 +51,21 @@ export const useCspBreadcrumbs = (breadcrumbs: CspNavigationItem[]) => { }; }); - setBreadcrumbs([ + const nextBreadcrumbs = [ { text: CLOUD_POSTURE, - onClick: (e) => { + onClick: (e: React.MouseEvent) => { e.preventDefault(); history.push(`/`); }, }, ...additionalBreadCrumbs, - ]); - }, [match.path, getUrlForApp, setBreadcrumbs, breadcrumbs, history]); + ]; + + setBreadcrumbs(nextBreadcrumbs); + docTitle.change(getTextBreadcrumbs(nextBreadcrumbs)); + }, [match.path, getUrlForApp, setBreadcrumbs, breadcrumbs, history, docTitle]); }; + +const getTextBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => + breadcrumbs.map((breadcrumb) => breadcrumb.text).filter(string.is); diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts index 12c1af65f95555..9b0c6b48d898c4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.test.ts @@ -17,8 +17,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -51,8 +51,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` @@ -106,8 +106,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -127,8 +127,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -165,8 +165,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install \` --fleet-server-es=http://elasticsearch:9200 \` @@ -226,8 +226,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.zip - tar xzvf elastic-agent--linux-x86_64.zip + "curl -L -O https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--linux-x86_64.tar.gz + tar xzvf elastic-agent--linux-x86_64.tar.gz cd elastic-agent--linux-x86_64 sudo ./elastic-agent install--url=http://fleetserver:8220 \\\\ --fleet-server-es=http://elasticsearch:9200 \\\\ @@ -276,8 +276,8 @@ describe('getInstallCommandForPlatform', () => { ); expect(res).toMatchInlineSnapshot(` - "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.tar.gz -OutFile elastic-agent--windows-x86_64.tar.gz - Expand-Archive .\\\\elastic-agent--windows-x86_64.tar.gz + "wget https://artifacts.elastic.co/downloads/beats/elastic-agent/elastic-agent--windows-x86_64.zip -OutFile elastic-agent--windows-x86_64.zip + Expand-Archive .\\\\elastic-agent--windows-x86_64.zip cd elastic-agent--windows-x86_64 .\\\\elastic-agent.exe install --url=http://fleetserver:8220 \` --fleet-server-es=http://elasticsearch:9200 \` diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts index ed38478c3a3eee..a1cc63c5bd9778 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/components/fleet_server_instructions/utils/install_command_utils.ts @@ -19,8 +19,8 @@ function getArtifact(platform: PLATFORM_TYPE, kibanaVersion: string) { { fullUrl: string; filename: string; unpackedDir: string } > = { linux: { - fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.zip`, - filename: `elastic-agent-${kibanaVersion}-linux-x86_64.zip`, + fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, + filename: `elastic-agent-${kibanaVersion}-linux-x86_64.tar.gz`, unpackedDir: `elastic-agent-${kibanaVersion}-linux-x86_64`, }, mac: { @@ -29,8 +29,8 @@ function getArtifact(platform: PLATFORM_TYPE, kibanaVersion: string) { unpackedDir: `elastic-agent-${kibanaVersion}-darwin-x86_64`, }, windows: { - fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-windows-x86_64.tar.gz`, - filename: `elastic-agent-${kibanaVersion}-windows-x86_64.tar.gz`, + fullUrl: `${ARTIFACT_BASE_URL}/elastic-agent-${kibanaVersion}-windows-x86_64.zip`, + filename: `elastic-agent-${kibanaVersion}-windows-x86_64.zip`, unpackedDir: `elastic-agent-${kibanaVersion}-windows-x86_64`, }, deb: { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx index 304239ac77dad7..4dab3b054bc8dd 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_select_create.tsx @@ -14,7 +14,7 @@ import { } from '../../applications/fleet/sections/agents/components'; import { AgentPolicyCreateInlineForm } from '../../applications/fleet/sections/agent_policy/components'; import type { AgentPolicy } from '../../types'; -import { incrementPolicyName } from '../../services'; +import { incrementPolicyName, policyHasFleetServer } from '../../services'; import { AgentPolicySelection } from '.'; @@ -48,6 +48,18 @@ export const SelectCreateAgentPolicy: React.FC = ({ ); }, [agentPolicies, excludeFleetServer]); + useEffect(() => { + // Select default value if policy has no fleet server + if ( + regularAgentPolicies.length === 1 && + !selectedPolicyId && + excludeFleetServer !== false && + !policyHasFleetServer(regularAgentPolicies[0]) + ) { + setSelectedPolicyId(regularAgentPolicies[0].id); + } + }, [regularAgentPolicies, selectedPolicyId, setSelectedPolicyId, excludeFleetServer]); + const onAgentPolicyChange = useCallback( async (key?: string, policy?: AgentPolicy) => { if (policy) { diff --git a/x-pack/plugins/monitoring/common/http_api/_health/index.ts b/x-pack/plugins/monitoring/common/http_api/_health/index.ts new file mode 100644 index 00000000000000..50fdb597813ece --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/_health/index.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. + */ + +import * as rt from 'io-ts'; +import { numberFromStringRT, timestampFromStringRT } from '../shared'; + +export const getHealthRequestQueryRT = rt.partial({ + min: timestampFromStringRT, + max: timestampFromStringRT, + timeout: numberFromStringRT, +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/index.ts b/x-pack/plugins/monitoring/common/http_api/shared/index.ts index 4e47c7239e39fe..44b103d0468240 100644 --- a/x-pack/plugins/monitoring/common/http_api/shared/index.ts +++ b/x-pack/plugins/monitoring/common/http_api/shared/index.ts @@ -10,5 +10,6 @@ export * from './cluster'; export * from './literal_value'; export * from './pagination'; export * from './query_string_boolean'; +export * from './query_string_number'; export * from './sorting'; export * from './time_range'; diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts new file mode 100644 index 00000000000000..30d2167eef5861 --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.test.ts @@ -0,0 +1,21 @@ +/* + * 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 { either } from 'fp-ts'; +import { numberFromStringRT } from './query_string_number'; + +describe('NumberFromString runtime type', () => { + it('decodes strings to numbers', () => { + expect(numberFromStringRT.decode('123')).toEqual(either.right(123)); + expect(numberFromStringRT.decode('0')).toEqual(either.right(0)); + }); + + it('rejects when not a number', () => { + expect(either.isLeft(numberFromStringRT.decode(''))).toBeTruthy(); + expect(either.isLeft(numberFromStringRT.decode('ab12'))).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.ts b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.ts new file mode 100644 index 00000000000000..24305690dad08e --- /dev/null +++ b/x-pack/plugins/monitoring/common/http_api/shared/query_string_number.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const numberFromStringRT = new rt.Type( + 'NumberFromString', + rt.number.is, + (value, context) => { + const nb = parseInt(value as string, 10); + return isNaN(nb) ? rt.failure(value, context) : rt.success(nb); + }, + String +); diff --git a/x-pack/plugins/monitoring/common/types/es.ts b/x-pack/plugins/monitoring/common/types/es.ts index a6b91f22ae5638..977753de42d97a 100644 --- a/x-pack/plugins/monitoring/common/types/es.ts +++ b/x-pack/plugins/monitoring/common/types/es.ts @@ -6,6 +6,7 @@ */ export interface ElasticsearchResponse { + timed_out?: boolean; hits?: { hits: ElasticsearchResponseHit[]; total: { diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md b/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md new file mode 100644 index 00000000000000..21d6cff557e288 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/README.md @@ -0,0 +1,13 @@ +### Stack Monitoring Health API + +A endpoint that makes a handful of pre-determined queries to determine the health/status of stack monitoring for the configured kibana. + +GET /api/monitoring/v1/_health +parameters: +- (optional) min: start date of the queries, in ms or YYYY-MM-DD hh:mm:ss +- (optional) max: end date of the queries, in ms or YYYY-MM-DD hh:mm:ss +- (optional) timeout: maximum timeout of the queries, in seconds + +The response includes sections that can provide useful informations in a debugging context: +- settings: a subset of the kibana.yml settings relevant to stack monitoring +- monitoredClusters: a representation of the monitoring documents available to the running kibana. It exposes which metricsets are collected by what collection mode and when was the last time it was ingested. The query groups the metricsets by products and can help identifying missing documents that could explain why a page is not loading or crashing diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.ts new file mode 100644 index 00000000000000..32f3bcaa90237b --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/index.ts @@ -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 { LegacyRequest, MonitoringCore } from '../../../../types'; +import { MonitoringConfig } from '../../../../config'; +import { createValidationFunction } from '../../../../lib/create_route_validation_function'; +import { getHealthRequestQueryRT } from '../../../../../common/http_api/_health'; +import { TimeRange } from '../../../../../common/http_api/shared'; +import { INDEX_PATTERN, INDEX_PATTERN_ENTERPRISE_SEARCH } from '../../../../../common/constants'; + +import { fetchMonitoredClusters } from './monitored_clusters'; + +const DEFAULT_QUERY_TIMERANGE = { min: 'now-15m', max: 'now' }; +const DEFAULT_QUERY_TIMEOUT_SECONDS = 15; + +export function registerV1HealthRoute(server: MonitoringCore) { + const validateQuery = createValidationFunction(getHealthRequestQueryRT); + + const withCCS = (indexPattern: string) => { + if (server.config.ui.ccs.enabled) { + return `${indexPattern},*:${indexPattern}`; + } + return indexPattern; + }; + + server.route({ + method: 'get', + path: '/api/monitoring/v1/_health', + validate: { + query: validateQuery, + }, + async handler(req: LegacyRequest) { + const logger = req.getLogger(); + const timeRange = { + min: req.query.min || DEFAULT_QUERY_TIMERANGE.min, + max: req.query.max || DEFAULT_QUERY_TIMERANGE.max, + } as TimeRange; + const timeout = req.query.timeout || DEFAULT_QUERY_TIMEOUT_SECONDS; + const { callWithRequest } = req.server.plugins.elasticsearch.getCluster('monitoring'); + + const settings = extractSettings(server.config); + + const monitoredClusters = await fetchMonitoredClusters({ + timeout, + timeRange, + monitoringIndex: withCCS(INDEX_PATTERN), + entSearchIndex: withCCS(INDEX_PATTERN_ENTERPRISE_SEARCH), + search: (params: any) => callWithRequest(req, 'search', params), + logger, + }).catch((err: Error) => { + logger.error(`_health: failed to retrieve monitored clusters:\n${err.stack}`); + return { error: err.message }; + }); + + return { monitoredClusters, settings }; + }, + }); +} + +function extractSettings(config: MonitoringConfig) { + return { + ccs: config.ui.ccs.enabled, + logsIndex: config.ui.logs.index, + metricbeatIndex: config.ui.metricbeat.index, + hasRemoteClusterConfigured: (config.ui.elasticsearch.hosts || []).some(Boolean), + }; +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts new file mode 100644 index 00000000000000..9248af4cae8237 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.test.ts @@ -0,0 +1,60 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import assert from 'assert'; +import sinon from 'sinon'; +import { buildMonitoredClusters } from './build_monitored_clusters'; + +describe(__filename, () => { + describe('buildMonitoringClusters', () => { + test('it should build a representation of the monitoring state', () => { + const clustersBuckets = [ + { + key: 'cluster_one', + elasticsearch: { + buckets: [ + { + key: 'node_one', + shard: { + by_index: { + buckets: [ + { + key: '.ds-.monitoring-es-8-mb.2022', + last_seen: { + value: 123, + value_as_string: '2022-01-01', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ]; + + const logger = { warn: sinon.spy() } as unknown as Logger; + const monitoredClusters = buildMonitoredClusters(clustersBuckets, logger); + assert.deepEqual(monitoredClusters, { + cluster_one: { + elasticsearch: { + node_one: { + shard: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb.2022', + lastSeen: '2022-01-01', + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts new file mode 100644 index 00000000000000..df959c499d4b2e --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/build_monitored_clusters.ts @@ -0,0 +1,150 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { isEmpty, mapValues, merge, omitBy, reduce } from 'lodash'; + +enum CollectionMode { + Internal = 'internal-monitoring', + Metricbeat7 = 'metricbeat-7', + Metricbeat8 = 'metricbeat-8', + Unknown = 'unknown', +} + +enum MonitoredProduct { + Cluster = 'cluster', + Elasticsearch = 'elasticsearch', + Kibana = 'kibana', + Beats = 'beats', + Logstash = 'logstash', + EnterpriseSearch = 'enterpriseSearch', +} + +interface MonitoredMetricsets { + [metricset: string]: { + [collectionMode in CollectionMode]: { + index: string; + lastSeen: string; + }; + }; +} + +interface MonitoredEntities { + [entityId: string]: MonitoredMetricsets; +} + +type MonitoredProducts = { + [product in MonitoredProduct]: MonitoredEntities; +}; + +export interface MonitoredClusters { + [clusterUuid: string]: MonitoredProducts; +} + +const internalMonitoringPattern = /^\.monitoring-(es|kibana|beats|logstash)-7-[0-9]{4}\..*/; +const metricbeatMonitoring7Pattern = /^\.monitoring-(es|kibana|beats|logstash|ent-search)-7.*-mb.*/; +const metricbeatMonitoring8Pattern = + /^\.ds-\.monitoring-(es|kibana|beats|logstash|ent-search)-8-mb.*/; + +const getCollectionMode = (index: string): CollectionMode => { + if (internalMonitoringPattern.test(index)) return CollectionMode.Internal; + if (metricbeatMonitoring7Pattern.test(index)) return CollectionMode.Metricbeat7; + if (metricbeatMonitoring8Pattern.test(index)) return CollectionMode.Metricbeat8; + + return CollectionMode.Unknown; +}; + +/** + * builds a normalized representation of the monitoring state from the provided + * query buckets with a cluster->product->entity->metricset hierarchy where + * cluster: the monitored cluster identifier + * product: the monitored products (eg elasticsearch) + * entity: the product unit of work (eg node) + * metricset: the collected metricsets for a given entity + * + * example: + * { + * "f-05NylTQg2G7rQXHnvYbA": { + * "elasticsearch": { + * "9NXA8Ov5QCeWAalKIHWFJg": { + * "shard": { + * "metricbeat-8": { + * "index": ".ds-.monitoring-es-8-mb-2022.05.17-000001", + * "lastSeen": "2022-05-17T16:56:52.929Z" + * } + * } + * } + * } + * } + * } + */ +export const buildMonitoredClusters = ( + clustersBuckets: any[], + logger: Logger +): MonitoredClusters => { + return clustersBuckets.reduce((clusters, { key, doc_count: _, ...products }) => { + clusters[key] = buildMonitoredProducts(products, logger); + return clusters; + }, {}); +}; + +/** + * some products may not have a common identifier for their entities across the + * metricsets and can create multiple aggregations. we make sure to merge these + * so the output only includes a single product entry + * we assume each aggregation is named as /productname(_aggsuffix)?/ + */ +const buildMonitoredProducts = (rawProducts: any, logger: Logger): MonitoredProducts => { + const validProducts = Object.values(MonitoredProduct); + const products = mapValues(rawProducts, (value, key) => { + if (!validProducts.some((product) => key.startsWith(product))) { + logger.warn(`buildMonitoredProducts: ignoring unknown product aggregation key (${key})`); + return {}; + } + + return buildMonitoredEntities(value.buckets); + }); + + return reduce( + products, + (uniqProducts: any, entities: any, aggregationKey: string) => { + if (isEmpty(entities)) return uniqProducts; + + const product = aggregationKey.split('_')[0]; + uniqProducts[product] = merge(uniqProducts[product], entities); + return uniqProducts; + }, + {} + ); +}; + +const buildMonitoredEntities = (entitiesBuckets: any[]): MonitoredEntities => { + return entitiesBuckets.reduce( + (entities, { key, key_as_string: keyAsString, doc_count: _, ...metricsets }) => { + entities[keyAsString || key] = buildMonitoredMetricsets(metricsets); + return entities; + }, + {} + ); +}; + +const buildMonitoredMetricsets = (rawMetricsets: any): MonitoredMetricsets => { + const monitoredMetricsets = mapValues( + rawMetricsets, + ({ by_index: byIndex }: { by_index: { buckets: any[] } }) => { + return byIndex.buckets.reduce((metricsets, { key, last_seen: lastSeen }) => { + metricsets[getCollectionMode(key)] = { + index: key, + lastSeen: lastSeen.value_as_string, + }; + return metricsets; + }, {}); + } + ); + + return omitBy(monitoredMetricsets, isEmpty) as unknown as MonitoredMetricsets; +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts new file mode 100644 index 00000000000000..fabece8a6b2079 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.test.ts @@ -0,0 +1,328 @@ +/* + * 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 assert from 'assert'; +import sinon from 'sinon'; +import type { Logger } from '@kbn/core/server'; +import { fetchMonitoredClusters } from './fetch_monitored_clusters'; + +const getMockLogger = () => + ({ + warn: sinon.spy(), + error: sinon.spy(), + } as unknown as Logger); + +describe(__filename, () => { + describe('fetchMonitoringClusters', () => { + test('it should send multiple search queries', async () => { + const searchFn = jest.fn().mockResolvedValue({ + aggregations: { + clusters: { + buckets: [], + }, + }, + }); + + await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(searchFn.mock.calls.length, 3); + }); + + test('it should report request timeouts', async () => { + const searchFn = jest + .fn() + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }) + .mockResolvedValueOnce({ + timed_out: true, + aggregations: {}, + }) + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }); + + const result = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(result.execution.timedOut, true); + }); + + test('it should report request errors', async () => { + const searchFn = jest + .fn() + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }) + .mockRejectedValueOnce(new Error('massive failure')) + .mockResolvedValueOnce({ + timed_out: false, + aggregations: {}, + }); + + const result = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.equal(result.execution.timedOut, false); + assert.deepEqual(result.execution.errors, ['massive failure']); + }); + + test('it should merge the query results', async () => { + const mainMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + elasticsearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'es-node-id.1', + doc_count: 540, + enrich: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + + kibana: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'kibana-node-id.1', + doc_count: 540, + stats: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-kibana-8-mb-2022.05.19-000001', + doc_count: 162, + last_seen: { + value: 1652975513680, + value_as_string: '2022-05-19T15:51:53.680Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const persistentMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + elasticsearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'es-node-id.1', + doc_count: 540, + shard: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const entsearchMetricsetsResponse = { + aggregations: { + clusters: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'cluster-id.1', + doc_count: 11874, + enterpriseSearch: { + meta: {}, + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'ent-search-node-id.1', + doc_count: 540, + health: { + meta: {}, + doc_count: 180, + by_index: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '.ds-.monitoring-ent-search-8-mb-2022.05.19-000001', + doc_count: 180, + last_seen: { + value: 1652975511716, + value_as_string: '2022-05-19T15:51:51.716Z', + }, + }, + ], + }, + }, + }, + ], + }, + }, + ], + }, + }, + }; + + const searchFn = jest + .fn() + .mockResolvedValueOnce(mainMetricsetsResponse) + .mockResolvedValueOnce(persistentMetricsetsResponse) + .mockResolvedValueOnce(entsearchMetricsetsResponse); + + const monitoredClusters = await fetchMonitoredClusters({ + timeout: 10, + monitoringIndex: 'foo', + entSearchIndex: 'foo', + timeRange: { min: 1652979091217, max: 11652979091217 }, + search: searchFn, + logger: getMockLogger(), + }); + + assert.deepEqual(monitoredClusters, { + execution: { + timedOut: false, + errors: [], + }, + + clusters: { + 'cluster-id.1': { + elasticsearch: { + 'es-node-id.1': { + enrich: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + shard: { + 'metricbeat-8': { + index: '.ds-.monitoring-es-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + }, + }, + + kibana: { + 'kibana-node-id.1': { + stats: { + 'metricbeat-8': { + index: '.ds-.monitoring-kibana-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:53.680Z', + }, + }, + }, + }, + + enterpriseSearch: { + 'ent-search-node-id.1': { + health: { + 'metricbeat-8': { + index: '.ds-.monitoring-ent-search-8-mb-2022.05.19-000001', + lastSeen: '2022-05-19T15:51:51.716Z', + }, + }, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts new file mode 100644 index 00000000000000..95f56aea5f6259 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/fetch_monitored_clusters.ts @@ -0,0 +1,98 @@ +/* + * 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 type { Logger } from '@kbn/core/server'; +import { merge } from 'lodash'; + +import { ElasticsearchResponse } from '../../../../../../common/types/es'; +import { TimeRange } from '../../../../../../common/http_api/shared'; + +import { buildMonitoredClusters, MonitoredClusters } from './build_monitored_clusters'; +import { + monitoredClustersQuery, + persistentMetricsetsQuery, + enterpriseSearchQuery, +} from './monitored_clusters_query'; + +type SearchFn = (params: any) => Promise; + +interface MonitoredClustersResponse { + clusters?: MonitoredClusters; + execution: { + timedOut?: boolean; + errors?: string[]; + }; +} + +/** + * executes multiple search requests to build a representation of the monitoring + * documents. the queries aggregations are built with a similar hierarchy so + * we can merge them to a single output + */ +export const fetchMonitoredClusters = async ({ + timeout, + monitoringIndex, + entSearchIndex, + timeRange, + search, + logger, +}: { + timeout: number; + timeRange: TimeRange; + monitoringIndex: string; + entSearchIndex: string; + search: SearchFn; + logger: Logger; +}): Promise => { + const getMonitoredClusters = async ( + index: string, + body: any + ): Promise => { + try { + const { aggregations, timed_out: timedOut } = await search({ + index, + body, + size: 0, + ignore_unavailable: true, + }); + + const buckets = aggregations?.clusters?.buckets ?? []; + return { + clusters: buildMonitoredClusters(buckets, logger), + execution: { timedOut }, + }; + } catch (err) { + logger.error(`fetchMonitoredClusters: failed to fetch:\n${err.stack}`); + return { execution: { errors: [err.message] } }; + } + }; + + const results = await Promise.all([ + getMonitoredClusters(monitoringIndex, monitoredClustersQuery({ timeRange, timeout })), + getMonitoredClusters(monitoringIndex, persistentMetricsetsQuery({ timeout })), + getMonitoredClusters(entSearchIndex, enterpriseSearchQuery({ timeRange, timeout })), + ]); + + return { + clusters: merge({}, ...results.map(({ clusters }) => clusters)), + + execution: results + .map(({ execution }) => execution) + .reduce( + (acc, execution) => { + return { + timedOut: Boolean(acc.timedOut || execution.timedOut), + errors: acc.errors!.concat(execution.errors || []), + }; + }, + { + timedOut: false, + errors: [], + } + ), + }; +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/index.ts new file mode 100644 index 00000000000000..b8282afe3b43dd --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { fetchMonitoredClusters } from './fetch_monitored_clusters'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts new file mode 100644 index 00000000000000..825cabc7232946 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/_health/monitored_clusters/monitored_clusters_query.ts @@ -0,0 +1,480 @@ +/* + * 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 { TimeRange } from '../../../../../../common/http_api/shared'; + +const MAX_BUCKET_SIZE = 100; + +interface QueryOptions { + timeRange?: TimeRange; + timeout: number; // in seconds +} + +/** + * returns a nested aggregation of the monitored products per cluster, standalone + * included. each product aggregation retrieves the related metricsets and the + * last time they were ingested. + * if a product requires multiple aggregations the key is suffixed with an identifer + * separated by an underscore. eg beats_state + */ +export const monitoredClustersQuery = ({ timeRange, timeout }: QueryOptions) => { + if (!timeRange) throw new Error('monitoredClustersQuery: missing timeRange parameter'); + + return { + timeout: `${timeout}s`, + query: { + bool: { + filter: [ + { + range: { + timestamp: { + gte: timeRange.min, + lte: timeRange.max, + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: clusterUuidTerm, + aggs: { + ...beatsAggregations, + cluster: clusterAggregation, + elasticsearch: esAggregation, + kibana: kibanaAggregation, + logstash: logstashAggregation, + }, + }, + }, + }; +}; + +/** + * some metricset documents use a stable ID to maintain a single occurence of + * the documents in the index. we query those metricsets separately without + * a time range filter + */ +export const persistentMetricsetsQuery = ({ timeout }: QueryOptions) => { + const metricsetsAggregations = { + elasticsearch: { + terms: { + field: 'source_node.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + shard: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'shards', + }, + }, + { + term: { + 'metricset.name': 'shard', + }, + }, + ], + }, + }, + }), + }, + }, + + logstash: { + terms: { + field: 'logstash_state.pipeline.id', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'logstash_state', + }, + }, + { + term: { + 'metricset.name': 'node', + }, + }, + ], + }, + }, + }), + }, + }, + }; + + return { + timeout: `${timeout}s`, + aggs: { + clusters: { + terms: clusterUuidTerm, + aggs: metricsetsAggregations, + }, + }, + }; +}; + +export const enterpriseSearchQuery = ({ timeRange, timeout }: QueryOptions) => { + if (!timeRange) throw new Error('enterpriseSearchQuery: missing timeRange parameter'); + + const timestampField = '@timestamp'; + return { + timeout: `${timeout}s`, + query: { + bool: { + filter: [ + { + range: { + [timestampField]: { + gte: timeRange.min, + lte: timeRange.max, + }, + }, + }, + ], + }, + }, + aggs: { + clusters: { + terms: { + field: 'enterprisesearch.cluster_uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + enterpriseSearch: { + terms: { + field: 'agent.id', + }, + aggs: { + health: lastSeenByIndex( + { + filter: { + bool: { + should: [ + { + term: { + 'metricset.name': 'health', + }, + }, + ], + }, + }, + }, + timestampField + ), + + stats: lastSeenByIndex( + { + filter: { + bool: { + should: [ + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }, + timestampField + ), + }, + }, + }, + }, + }, + }; +}; + +const clusterUuidTerm = { field: 'cluster_uuid', missing: 'standalone', size: 100 }; + +const lastSeenByIndex = (aggregation: { filter: any }, timestampField = 'timestamp') => { + return { + ...aggregation, + aggs: { + by_index: { + terms: { + field: '_index', + size: MAX_BUCKET_SIZE, + }, + aggs: { + last_seen: { + max: { + field: timestampField, + }, + }, + }, + }, + }, + }; +}; + +const clusterAggregation = { + terms: { + field: 'cluster_uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + cluster_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'cluster_stats', + }, + }, + { + term: { + 'metricset.name': 'cluster_stats', + }, + }, + ], + }, + }, + }), + + ccr: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'ccr_stats', + }, + }, + { + term: { + 'metricset.name': 'ccr', + }, + }, + ], + }, + }, + }), + + index: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'index_stats', + }, + }, + { + term: { + 'metricset.name': 'index', + }, + }, + ], + }, + }, + }), + + index_summary: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'indices_stats', + }, + }, + { + term: { + 'metricset.name': 'index_summary', + }, + }, + ], + }, + }, + }), + + index_recovery: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'index_recovery', + }, + }, + { + term: { + 'metricset.name': 'index_recovery', + }, + }, + ], + }, + }, + }), + }, +}; + +// ignore the enrich metricset since it's not used by stack monitoring +const esAggregation = { + terms: { + field: 'node_stats.node_id', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'node_stats', + }, + }, + { + term: { + 'metricset.name': 'node_stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const kibanaAggregation = { + terms: { + field: 'kibana_stats.kibana.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'kibana_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const logstashAggregation = { + terms: { + field: 'logstash_stats.logstash.uuid', + size: MAX_BUCKET_SIZE, + }, + aggs: { + node_stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'logstash_stats', + }, + }, + { + term: { + 'metricset.name': 'node_stats', + }, + }, + ], + }, + }, + }), + }, +}; + +const beatsAggregations = { + beats_stats: { + multi_terms: { + size: MAX_BUCKET_SIZE, + terms: [ + { + field: 'beats_stats.beat.type', + }, + { + field: 'beats_stats.beat.uuid', + }, + ], + }, + aggs: { + stats: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'beats_stats', + }, + }, + { + term: { + 'metricset.name': 'stats', + }, + }, + ], + }, + }, + }), + }, + }, + + beats_state: { + multi_terms: { + size: MAX_BUCKET_SIZE, + terms: [ + { + field: 'beats_state.beat.type', + }, + { + field: 'beats_state.beat.uuid', + }, + ], + }, + aggs: { + state: lastSeenByIndex({ + filter: { + bool: { + should: [ + { + term: { + type: 'beats_state', + }, + }, + { + term: { + 'metricset.name': 'state', + }, + }, + ], + }, + }, + }), + }, + }, +}; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts index f2a1830006174f..7ec331075655b2 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/index.ts +++ b/x-pack/plugins/monitoring/server/routes/api/v1/index.ts @@ -13,6 +13,7 @@ export { registerV1ClusterRoutes } from './cluster'; export { registerV1ElasticsearchRoutes } from './elasticsearch'; export { registerV1ElasticsearchSettingsRoutes } from './elasticsearch_settings'; export { registerV1EnterpriseSearchRoutes } from './enterprise_search'; +export { registerV1HealthRoute } from './_health'; export { registerV1LogstashRoutes } from './logstash'; export { registerV1SetupRoutes } from './setup'; export { registerV1KibanaRoutes } from './kibana'; diff --git a/x-pack/plugins/monitoring/server/routes/index.ts b/x-pack/plugins/monitoring/server/routes/index.ts index 32f2f06188d951..ce5d87a55f1da2 100644 --- a/x-pack/plugins/monitoring/server/routes/index.ts +++ b/x-pack/plugins/monitoring/server/routes/index.ts @@ -17,6 +17,7 @@ import { registerV1ElasticsearchRoutes, registerV1ElasticsearchSettingsRoutes, registerV1EnterpriseSearchRoutes, + registerV1HealthRoute, registerV1LogstashRoutes, registerV1SetupRoutes, registerV1KibanaRoutes, @@ -39,6 +40,7 @@ export function requireUIRoutes( registerV1ElasticsearchRoutes(decoratedServer); registerV1ElasticsearchSettingsRoutes(decoratedServer, npRoute); registerV1EnterpriseSearchRoutes(decoratedServer); + registerV1HealthRoute(decoratedServer); registerV1LogstashRoutes(decoratedServer); registerV1SetupRoutes(decoratedServer); registerV1KibanaRoutes(decoratedServer); diff --git a/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json new file mode 100644 index 00000000000000..d8bb99e30fc2b1 --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_empty.json @@ -0,0 +1,9 @@ +{ + "monitoredClusters": { + "clusters": {}, + "execution": { + "timedOut": false, + "errors": [] + } + } +} diff --git a/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js new file mode 100644 index 00000000000000..2b9ae96ba034b9 --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/fixtures/response_es_beats.js @@ -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 moment from 'moment'; + +export const esBeatsResponse = (date = moment().format('YYYY.MM.DD')) => { + const esIndex = `.ds-.monitoring-es-8-mb-${date}-000001`; + const beatsIndex = `.ds-.monitoring-beats-8-mb-${date}-000001`; + + return { + monitoredClusters: { + clusters: { + tqiiSubfSgWrl68VJn4y2g: { + cluster: { + tqiiSubfSgWrl68VJn4y2g: { + index_summary: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:15.135Z', + }, + }, + index_recovery: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:13.584Z', + }, + }, + index: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:11.994Z', + }, + }, + cluster_stats: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:18.917Z', + }, + }, + }, + }, + elasticsearch: { + QR7smK2oReK_jWHtt5UOSQ: { + node_stats: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:10:14.268Z', + }, + }, + shard: { + 'metricbeat-8': { + index: esIndex, + lastSeen: '2022-05-23T22:09:48.321Z', + }, + }, + }, + }, + beats: { + 'metricbeat|726039e5-67fe-4e78-837f-072cf2ba0fe7': { + stats: { + 'metricbeat-8': { + index: beatsIndex, + lastSeen: '2022-05-23T22:17:10.622Z', + }, + }, + state: { + 'metricbeat-8': { + index: beatsIndex, + lastSeen: '2022-05-23T22:17:08.837Z', + }, + }, + }, + }, + }, + }, + execution: { + timedOut: false, + errors: [], + }, + }, + }; +}; diff --git a/x-pack/test/api_integration/apis/monitoring/_health/index.js b/x-pack/test/api_integration/apis/monitoring/_health/index.js new file mode 100644 index 00000000000000..cae6da2b3a13ca --- /dev/null +++ b/x-pack/test/api_integration/apis/monitoring/_health/index.js @@ -0,0 +1,83 @@ +/* + * 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 { mapValues } from 'lodash'; +import expect from '@kbn/expect'; + +import { getLifecycleMethods } from '../data_stream'; + +import emptyResponse from './fixtures/response_empty.json'; +import { esBeatsResponse } from './fixtures/response_es_beats'; + +export default function ({ getService }) { + const supertest = getService('supertest'); + + describe('_health endpoint', () => { + const timeRange = { + min: '2022-05-23T22:00:00.000Z', + max: '2022-05-23T22:30:00.000Z', + }; + + describe('no data', () => { + it('returns an empty state when no data', async () => { + const { body } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + delete body.settings; + expect(body).to.eql(emptyResponse); + }); + }); + + describe('with data', () => { + const archives = [ + 'x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8', + 'x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8', + ]; + const { setup, tearDown } = getLifecycleMethods(getService); + + before('load archive', () => { + return Promise.all(archives.map(setup)); + }); + + after('unload archive', () => { + return tearDown(); + }); + + it('returns the state of the monitoring documents', async () => { + const { body } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + delete body.settings; + expect(body).to.eql(esBeatsResponse()); + }); + + it('returns relevant settings', async () => { + const { + body: { settings }, + } = await supertest + .get(`/api/monitoring/v1/_health?min=${timeRange.min}&max=${timeRange.max}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + + // we only test the structure of the settings and not the actual values + // to avoid coupling our tests with any underlying changes to default + // configuration + const settingsType = mapValues(settings, (value) => typeof value); + expect(settingsType).to.eql({ + ccs: 'boolean', + logsIndex: 'string', + metricbeatIndex: 'string', + hasRemoteClusterConfigured: 'boolean', + }); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz new file mode 100644 index 00000000000000..9fbc5a31fe7046 Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_beats_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz new file mode 100644 index 00000000000000..5d47ccc83c1dfb Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_es_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz new file mode 100644 index 00000000000000..55de0a6e215d74 Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_kibana_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz new file mode 100644 index 00000000000000..740d9400460e2c Binary files /dev/null and b/x-pack/test/api_integration/apis/monitoring/es_archives/_health/monitoring_logstash_standalone_8/data.json.gz differ diff --git a/x-pack/test/api_integration/apis/monitoring/index.js b/x-pack/test/api_integration/apis/monitoring/index.js index 476911c904ea6d..ebbf9c3af16e7b 100644 --- a/x-pack/test/api_integration/apis/monitoring/index.js +++ b/x-pack/test/api_integration/apis/monitoring/index.js @@ -18,5 +18,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./standalone_cluster')); loadTestFile(require.resolve('./logs')); loadTestFile(require.resolve('./setup')); + loadTestFile(require.resolve('./_health')); }); }