diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/ecs_allowed_values/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/ecs_allowed_values/index.test.tsx new file mode 100644 index 0000000000000..07af65877da01 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/ecs_allowed_values/index.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { mockAllowedValues } from '../../mock/allowed_values/mock_allowed_values'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { EcsAllowedValues } from '.'; + +describe('EcsAllowedValues', () => { + describe('when `allowedValues` exists', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders the allowed values', () => { + expect(screen.getByTestId('ecsAllowedValues')).toHaveTextContent( + mockAllowedValues.map(({ name }) => `${name}`).join('') + ); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('ecsAllowedValuesEmpty')).not.toBeInTheDocument(); + }); + }); + + describe('when `allowedValues` is undefined', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it does NOT render the allowed values', () => { + expect(screen.queryByTestId('ecsAllowedValues')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('ecsAllowedValuesEmpty')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/index.test.tsx similarity index 87% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/helpers.test.tsx rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/index.test.tsx index 75a34735abff1..e3e68152f4c06 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/helpers.test.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_common_table_columns/index.test.tsx @@ -10,57 +10,57 @@ import { omit } from 'lodash/fp'; import React from 'react'; import { SAME_FAMILY } from '../../data_quality_panel/same_family/translations'; -import { TestProviders } from '../../mock/test_providers'; import { eventCategory, eventCategoryWithUnallowedValues, -} from '../../mock/enriched_field_metadata'; +} from '../../mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { + DOCUMENT_VALUES_ACTUAL, + ECS_DESCRIPTION, + ECS_MAPPING_TYPE_EXPECTED, + ECS_VALUES_EXPECTED, + FIELD, + INDEX_MAPPING_TYPE_ACTUAL, +} from '../translations'; import { EnrichedFieldMetadata } from '../../types'; import { EMPTY_PLACEHOLDER, getCommonTableColumns } from '.'; describe('getCommonTableColumns', () => { test('it returns the expected column configuration', () => { - const columns = getCommonTableColumns().map((x) => omit('render', x)); - - expect(columns).toEqual([ - { - field: 'indexFieldName', - name: 'Field', - sortable: true, - truncateText: false, - width: '20%', - }, + expect(getCommonTableColumns().map((x) => omit('render', x))).toEqual([ + { field: 'indexFieldName', name: FIELD, sortable: true, truncateText: false, width: '20%' }, { field: 'type', - name: 'ECS mapping type (expected)', + name: ECS_MAPPING_TYPE_EXPECTED, sortable: true, truncateText: false, width: '15%', }, { field: 'indexFieldType', - name: 'Index mapping type (actual)', + name: INDEX_MAPPING_TYPE_ACTUAL, sortable: true, truncateText: false, width: '15%', }, { field: 'allowed_values', - name: 'ECS values (expected)', + name: ECS_VALUES_EXPECTED, sortable: false, truncateText: false, width: '15%', }, { field: 'indexInvalidValues', - name: 'Document values (actual)', + name: DOCUMENT_VALUES_ACTUAL, sortable: false, truncateText: false, width: '15%', }, { field: 'description', - name: 'ECS description', + name: ECS_DESCRIPTION, sortable: false, truncateText: false, width: '20%', @@ -141,7 +141,7 @@ describe('getCommonTableColumns', () => { const withTypeMismatchDifferentFamily: EnrichedFieldMetadata = { ...eventCategory, // `event.category` is a `keyword` per the ECS spec indexFieldType, // this index has a mapping of `text` instead of `keyword` - isInSameFamily: false, // `text` and `keyword` are not in the same family + isInSameFamily: false, // `text` and `wildcard` are not in the same family }; render( @@ -159,29 +159,18 @@ describe('getCommonTableColumns', () => { }); describe('when the index field matches the ECS type', () => { - const indexFieldType = 'keyword'; - test('it renders the expected type with success styling', () => { const columns = getCommonTableColumns(); const indexFieldTypeColumnRender = columns[2].render; - const withTypeMismatchDifferentFamily: EnrichedFieldMetadata = { - ...eventCategory, // `event.category` is a `keyword` per the ECS spec - indexFieldType, // exactly matches the ECS spec - isInSameFamily: true, // `keyword` is a member of the `keyword` family - }; - render( {indexFieldTypeColumnRender != null && - indexFieldTypeColumnRender( - withTypeMismatchDifferentFamily.indexFieldType, - withTypeMismatchDifferentFamily - )} + indexFieldTypeColumnRender(eventCategory.indexFieldType, eventCategory)} ); - expect(screen.getByTestId('codeSuccess')).toHaveTextContent(indexFieldType); + expect(screen.getByTestId('codeSuccess')).toHaveTextContent(eventCategory.indexFieldType); }); }); }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/index.test.tsx similarity index 97% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/helpers.test.tsx rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/index.test.tsx index 160b300e08934..132e8b2fc302b 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/helpers.test.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/get_incompatible_mappings_table_columns/index.test.tsx @@ -10,8 +10,8 @@ import { omit } from 'lodash/fp'; import React from 'react'; import { SAME_FAMILY } from '../../data_quality_panel/same_family/translations'; -import { TestProviders } from '../../mock/test_providers'; -import { eventCategory } from '../../mock/enriched_field_metadata'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { eventCategory } from '../../mock/enriched_field_metadata/mock_enriched_field_metadata'; import { EnrichedFieldMetadata } from '../../types'; import { EMPTY_PLACEHOLDER, getIncompatibleMappingsTableColumns } from '.'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.test.tsx new file mode 100644 index 0000000000000..7c72289290942 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.test.tsx @@ -0,0 +1,414 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { omit } from 'lodash/fp'; +import React from 'react'; + +import { + EMPTY_PLACEHOLDER, + getCustomTableColumns, + getEcsCompliantTableColumns, + getIncompatibleValuesTableColumns, +} from './helpers'; +import { + eventCategory, + eventCategoryWithUnallowedValues, + someField, +} from '../mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { TestProviders } from '../mock/test_providers/test_providers'; + +describe('helpers', () => { + describe('getCustomTableColumns', () => { + test('it returns the expected columns', () => { + expect(getCustomTableColumns().map((x) => omit('render', x))).toEqual([ + { + field: 'indexFieldName', + name: 'Field', + sortable: true, + truncateText: false, + width: '50%', + }, + { + field: 'indexFieldType', + name: 'Index mapping type', + sortable: true, + truncateText: false, + width: '50%', + }, + ]); + }); + + describe('indexFieldType render()', () => { + test('it renders the indexFieldType', () => { + const columns = getCustomTableColumns(); + const indexFieldTypeRender = columns[1].render; + + render( + + <> + {indexFieldTypeRender != null && + indexFieldTypeRender(someField.indexFieldType, someField)} + + + ); + + expect(screen.getByTestId('indexFieldType')).toHaveTextContent(someField.indexFieldType); + }); + }); + }); + + describe('getEcsCompliantTableColumns', () => { + test('it returns the expected columns', () => { + expect(getEcsCompliantTableColumns().map((x) => omit('render', x))).toEqual([ + { + field: 'indexFieldName', + name: 'Field', + sortable: true, + truncateText: false, + width: '25%', + }, + { + field: 'type', + name: 'ECS mapping type', + sortable: true, + truncateText: false, + width: '25%', + }, + { + field: 'allowed_values', + name: 'ECS values', + sortable: false, + truncateText: false, + width: '25%', + }, + { + field: 'description', + name: 'ECS description', + sortable: false, + truncateText: false, + width: '25%', + }, + ]); + }); + + describe('type render()', () => { + describe('when `type` exists', () => { + beforeEach(() => { + const columns = getEcsCompliantTableColumns(); + const typeRender = columns[1].render; + + render( + + <>{typeRender != null && typeRender(eventCategory.type, eventCategory)} + + ); + }); + + test('it renders the expected `type`', () => { + expect(screen.getByTestId('type')).toHaveTextContent('keyword'); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('typePlaceholder')).not.toBeInTheDocument(); + }); + }); + + describe('when `type` is undefined', () => { + beforeEach(() => { + const withUndefinedType = { + ...eventCategory, + type: undefined, // <-- + }; + const columns = getEcsCompliantTableColumns(); + const typeRender = columns[1].render; + + render( + + <>{typeRender != null && typeRender(withUndefinedType.type, withUndefinedType)} + + ); + }); + + test('it does NOT render the `type`', () => { + expect(screen.queryByTestId('type')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('typePlaceholder')).toHaveTextContent(EMPTY_PLACEHOLDER); + }); + }); + }); + + describe('allowed values render()', () => { + describe('when `allowedValues` exists', () => { + beforeEach(() => { + const columns = getEcsCompliantTableColumns(); + const allowedValuesRender = columns[2].render; + + render( + + <> + {allowedValuesRender != null && + allowedValuesRender(eventCategory.allowed_values, eventCategory)} + + + ); + }); + + test('it renders the expected `AllowedValue` names', () => { + expect(screen.getByTestId('ecsAllowedValues')).toHaveTextContent( + eventCategory.allowed_values?.map(({ name }) => name).join('') ?? '' + ); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('ecsAllowedValuesEmpty')).not.toBeInTheDocument(); + }); + }); + + describe('when `allowedValues` is undefined', () => { + const withUndefinedAllowedValues = { + ...eventCategory, + allowed_values: undefined, // <-- + }; + + beforeEach(() => { + const columns = getEcsCompliantTableColumns(); + const allowedValuesRender = columns[2].render; + + render( + + <> + {allowedValuesRender != null && + allowedValuesRender( + withUndefinedAllowedValues.allowed_values, + withUndefinedAllowedValues + )} + + + ); + }); + + test('it does NOT render the `AllowedValue` names', () => { + expect(screen.queryByTestId('ecsAllowedValues')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('ecsAllowedValuesEmpty')).toBeInTheDocument(); + }); + }); + }); + + describe('description render()', () => { + describe('when `description` exists', () => { + beforeEach(() => { + const columns = getEcsCompliantTableColumns(); + const descriptionRender = columns[3].render; + + render( + + <> + {descriptionRender != null && + descriptionRender(eventCategory.description, eventCategory)} + + + ); + }); + + test('it renders the expected `description`', () => { + expect(screen.getByTestId('description')).toHaveTextContent( + eventCategory.description?.replaceAll('\n', ' ') ?? '' + ); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('emptyPlaceholder')).not.toBeInTheDocument(); + }); + }); + + describe('when `description` is undefined', () => { + const withUndefinedDescription = { + ...eventCategory, + description: undefined, // <-- + }; + + beforeEach(() => { + const columns = getEcsCompliantTableColumns(); + const descriptionRender = columns[3].render; + + render( + + <> + {descriptionRender != null && + descriptionRender(withUndefinedDescription.description, withUndefinedDescription)} + + + ); + }); + + test('it does NOT render the `description`', () => { + expect(screen.queryByTestId('description')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('emptyPlaceholder')).toBeInTheDocument(); + }); + }); + }); + }); + + describe('getIncompatibleValuesTableColumns', () => { + test('it returns the expected columns', () => { + expect(getIncompatibleValuesTableColumns().map((x) => omit('render', x))).toEqual([ + { + field: 'indexFieldName', + name: 'Field', + sortable: true, + truncateText: false, + width: '25%', + }, + { + field: 'allowed_values', + name: 'ECS values (expected)', + sortable: false, + truncateText: false, + width: '25%', + }, + { + field: 'indexInvalidValues', + name: 'Document values (actual)', + sortable: false, + truncateText: false, + width: '25%', + }, + { + field: 'description', + name: 'ECS description', + sortable: false, + truncateText: false, + width: '25%', + }, + ]); + }); + + describe('allowed values render()', () => { + describe('when `allowedValues` exists', () => { + beforeEach(() => { + const columns = getIncompatibleValuesTableColumns(); + const allowedValuesRender = columns[1].render; + + render( + + <> + {allowedValuesRender != null && + allowedValuesRender(eventCategory.allowed_values, eventCategory)} + + + ); + }); + + test('it renders the expected `AllowedValue` names', () => { + expect(screen.getByTestId('ecsAllowedValues')).toHaveTextContent( + eventCategory.allowed_values?.map(({ name }) => name).join('') ?? '' + ); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('ecsAllowedValuesEmpty')).not.toBeInTheDocument(); + }); + }); + + describe('when `allowedValues` is undefined', () => { + const withUndefinedAllowedValues = { + ...eventCategory, + allowed_values: undefined, // <-- + }; + + beforeEach(() => { + const columns = getIncompatibleValuesTableColumns(); + const allowedValuesRender = columns[1].render; + + render( + + <> + {allowedValuesRender != null && + allowedValuesRender( + withUndefinedAllowedValues.allowed_values, + withUndefinedAllowedValues + )} + + + ); + }); + + test('it does NOT render the `AllowedValue` names', () => { + expect(screen.queryByTestId('ecsAllowedValues')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('ecsAllowedValuesEmpty')).toBeInTheDocument(); + }); + }); + }); + + describe('indexInvalidValues render()', () => { + describe('when `indexInvalidValues` is populated', () => { + beforeEach(() => { + const columns = getIncompatibleValuesTableColumns(); + const indexInvalidValuesRender = columns[2].render; + + render( + + <> + {indexInvalidValuesRender != null && + indexInvalidValuesRender( + eventCategoryWithUnallowedValues.indexInvalidValues, + eventCategoryWithUnallowedValues + )} + + + ); + }); + + test('it renders the expected `indexInvalidValues`', () => { + expect(screen.getByTestId('indexInvalidValues')).toHaveTextContent( + 'an_invalid_category (2)theory (1)' + ); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('emptyPlaceholder')).not.toBeInTheDocument(); + }); + }); + + describe('when `indexInvalidValues` is empty', () => { + beforeEach(() => { + const columns = getIncompatibleValuesTableColumns(); + const indexInvalidValuesRender = columns[2].render; + + render( + + <> + {indexInvalidValuesRender != null && + indexInvalidValuesRender(eventCategory.indexInvalidValues, eventCategory)} + + + ); + }); + + test('it does NOT render the index invalid values', () => { + expect(screen.queryByTestId('indexInvalidValues')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('emptyPlaceholder')).toBeInTheDocument(); + }); + }); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.tsx index e9a85c2908b89..8153380c140c3 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/helpers.tsx @@ -30,7 +30,9 @@ export const getCustomTableColumns = (): Array< { field: 'indexFieldType', name: i18n.INDEX_MAPPING_TYPE, - render: (indexFieldType: string) => {indexFieldType}, + render: (indexFieldType: string) => ( + {indexFieldType} + ), sortable: true, truncateText: false, width: '50%', @@ -50,8 +52,12 @@ export const getEcsCompliantTableColumns = (): Array< { field: 'type', name: i18n.ECS_MAPPING_TYPE, - render: (type: string) => - type != null ? {type} : {EMPTY_PLACEHOLDER}, + render: (type: string | undefined) => + type != null ? ( + {type} + ) : ( + {EMPTY_PLACEHOLDER} + ), sortable: true, truncateText: false, width: '25%', @@ -69,8 +75,12 @@ export const getEcsCompliantTableColumns = (): Array< { field: 'description', name: i18n.ECS_DESCRIPTION, - render: (description: string) => - description != null ? description : {EMPTY_PLACEHOLDER}, + render: (description: string | undefined) => + description != null ? ( + {description} + ) : ( + {EMPTY_PLACEHOLDER} + ), sortable: false, truncateText: false, width: '25%', diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.test.tsx new file mode 100644 index 0000000000000..8732f27702bc2 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { INCOMPATIBLE_FIELD_MAPPINGS_TABLE_TITLE } from '../data_quality_panel/tabs/incompatible_tab/translations'; +import { eventCategory } from '../mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { TestProviders } from '../mock/test_providers/test_providers'; +import { CompareFieldsTable } from '.'; +import { getIncompatibleMappingsTableColumns } from './get_incompatible_mappings_table_columns'; + +describe('CompareFieldsTable', () => { + describe('rendering', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders the expected title', () => { + expect(screen.getByTestId('title')).toHaveTextContent('Incompatible field mappings - foo'); + }); + + test('it renders the table', () => { + expect(screen.getByTestId('table')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.tsx index 2efc9355c710f..145686785cafa 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index.tsx @@ -36,11 +36,12 @@ const CompareFieldsTableComponent: React.FC = ({ return ( <> - <>{title} + {title} { + test('it renders a placeholder with the expected content when `indexInvalidValues` is empty', () => { + render( + + + + ); + + expect(screen.getByTestId('emptyPlaceholder')).toHaveTextContent(EMPTY_PLACEHOLDER); + }); + + test('it renders the expected field names and counts when the index has invalid values', () => { + const indexInvalidValues: UnallowedValueCount[] = [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ]; + + render( + + + + ); + + expect(screen.getByTestId('indexInvalidValues')).toHaveTextContent( + 'an_invalid_category (2)theory (1)' + ); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index_invalid_values/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index_invalid_values/index.tsx index d3df809215d08..2b58ea98b8b28 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index_invalid_values/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/compare_fields_table/index_invalid_values/index.tsx @@ -23,7 +23,7 @@ interface Props { const IndexInvalidValuesComponent: React.FC = ({ indexInvalidValues }) => indexInvalidValues.length === 0 ? ( - {EMPTY_PLACEHOLDER} + {EMPTY_PLACEHOLDER} ) : ( {indexInvalidValues.map(({ fieldName, count }, i) => ( diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/allowed_values/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/allowed_values/helpers.test.tsx new file mode 100644 index 0000000000000..7dde4254708b7 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/allowed_values/helpers.test.tsx @@ -0,0 +1,207 @@ +/* + * 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 { EcsFlat } from '@kbn/ecs'; +import { omit } from 'lodash/fp'; + +import { getUnallowedValueRequestItems, getValidValues, hasAllowedValues } from './helpers'; +import { AllowedValue, EcsMetadata } from '../../types'; + +const ecsMetadata: Record = EcsFlat as unknown as Record; + +describe('helpers', () => { + describe('hasAllowedValues', () => { + test('it returns true for a field that has `allowed_values`', () => { + expect( + hasAllowedValues({ + ecsMetadata, + fieldName: 'event.category', + }) + ).toBe(true); + }); + + test('it returns false for a field that does NOT have `allowed_values`', () => { + expect( + hasAllowedValues({ + ecsMetadata, + fieldName: 'host.name', + }) + ).toBe(false); + }); + + test('it returns false for a field that does NOT exist in `ecsMetadata`', () => { + expect( + hasAllowedValues({ + ecsMetadata, + fieldName: 'does.NOT.exist', + }) + ).toBe(false); + }); + + test('it returns false when `ecsMetadata` is null', () => { + expect( + hasAllowedValues({ + ecsMetadata: null, // <-- + fieldName: 'event.category', + }) + ).toBe(false); + }); + }); + + describe('getValidValues', () => { + test('it returns the expected valid values', () => { + expect(getValidValues(ecsMetadata['event.category'])).toEqual([ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ]); + }); + + test('it returns an empty array when the `field` does NOT have `allowed_values`', () => { + expect(getValidValues(ecsMetadata['host.name'])).toEqual([]); + }); + + test('it returns an empty array when `field` is undefined', () => { + expect(getValidValues(undefined)).toEqual([]); + }); + + test('it skips `allowed_values` where `name` is undefined', () => { + // omit the `name` property from the `database` `AllowedValue`: + const missingDatabase = + ecsMetadata['event.category'].allowed_values?.map((x) => + x.name === 'database' ? omit('name', x) : x + ) ?? []; + + const field = { + ...ecsMetadata['event.category'], + allowed_values: missingDatabase, + }; + + expect(getValidValues(field)).toEqual([ + 'authentication', + 'configuration', + // no entry for 'database' + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ]); + }); + }); + + describe('getUnallowedValueRequestItems', () => { + test('it returns the expected request items', () => { + expect( + getUnallowedValueRequestItems({ + ecsMetadata, + indexName: 'auditbeat-*', + }) + ).toEqual([ + { + indexName: 'auditbeat-*', + indexFieldName: 'event.category', + allowedValues: [ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ], + }, + { + indexName: 'auditbeat-*', + indexFieldName: 'event.kind', + allowedValues: [ + 'alert', + 'enrichment', + 'event', + 'metric', + 'state', + 'pipeline_error', + 'signal', + ], + }, + { + indexName: 'auditbeat-*', + indexFieldName: 'event.outcome', + allowedValues: ['failure', 'success', 'unknown'], + }, + { + indexName: 'auditbeat-*', + indexFieldName: 'event.type', + allowedValues: [ + 'access', + 'admin', + 'allowed', + 'change', + 'connection', + 'creation', + 'deletion', + 'denied', + 'end', + 'error', + 'group', + 'indicator', + 'info', + 'installation', + 'protocol', + 'start', + 'user', + ], + }, + ]); + }); + + test('it returns an empty array when `ecsMetadata` is null', () => { + expect( + getUnallowedValueRequestItems({ + ecsMetadata: null, // <-- + indexName: 'auditbeat-*', + }) + ).toEqual([]); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx new file mode 100644 index 0000000000000..8b7c9b01e3c5e --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.test.tsx @@ -0,0 +1,111 @@ +/* + * 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 { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../../helpers'; +import { alertIndexWithAllResults } from '../../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { PatternRollup } from '../../../types'; +import { Props, DataQualityDetails } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const patternIndexNames: Record = { + 'auditbeat-*': [ + '.ds-auditbeat-8.6.1-2023.02.07-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-custom-index-1', + ], + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], + 'packetbeat-*': [ + '.ds-packetbeat-8.5.3-2023.02.04-000001', + '.ds-packetbeat-8.6.1-2023.02.04-000001', + ], +}; + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: jest.fn(), + formatBytes, + formatNumber, + getGroupByFieldsOnClick: jest.fn(), + ilmPhases, + openCreateCaseFlyout: jest.fn(), + patternIndexNames, + patternRollups, + patterns, + theme: DARK_THEME, + updatePatternIndexNames: jest.fn(), + updatePatternRollup: jest.fn(), +}; + +describe('DataQualityDetails', () => { + describe('when ILM phases are provided', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the storage details', () => { + expect(screen.getByTestId('storageDetails')).toBeInTheDocument(); + }); + + test('it renders the indices details', () => { + expect(screen.getByTestId('indicesDetails')).toBeInTheDocument(); + }); + }); + + describe('when ILM phases are are empty', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders an empty prompt when ilmPhases is empty', () => { + expect(screen.getByTestId('ilmPhasesEmptyPrompt')).toBeInTheDocument(); + }); + + test('it does NOT render the storage details', () => { + expect(screen.queryByTestId('storageDetails')).not.toBeInTheDocument(); + }); + + test('it does NOT render the indices details', () => { + expect(screen.queryByTestId('indicesDetails')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx new file mode 100644 index 0000000000000..3c996dd095dc8 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/index.tsx @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + FlameElementEvent, + HeatmapElementEvent, + MetricElementEvent, + PartitionElementEvent, + Theme, + WordCloudElementEvent, + XYChartElementEvent, +} from '@elastic/charts'; + +import React, { useCallback, useState } from 'react'; + +import { IlmPhasesEmptyPrompt } from '../../../ilm_phases_empty_prompt'; +import { IndicesDetails } from './indices_details'; +import { StorageDetails } from './storage_details'; +import { PatternRollup, SelectedIndex } from '../../../types'; + +export interface Props { + addSuccessToast: (toast: { title: string }) => void; + canUserCreateAndReadCases: () => boolean; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; + getGroupByFieldsOnClick: ( + elements: Array< + | FlameElementEvent + | HeatmapElementEvent + | MetricElementEvent + | PartitionElementEvent + | WordCloudElementEvent + | XYChartElementEvent + > + ) => { + groupByField0: string; + groupByField1: string; + }; + ilmPhases: string[]; + openCreateCaseFlyout: ({ + comments, + headerContent, + }: { + comments: string[]; + headerContent?: React.ReactNode; + }) => void; + patternIndexNames: Record; + patternRollups: Record; + patterns: string[]; + theme: Theme; + updatePatternIndexNames: ({ + indexNames, + pattern, + }: { + indexNames: string[]; + pattern: string; + }) => void; + updatePatternRollup: (patternRollup: PatternRollup) => void; +} + +const DataQualityDetailsComponent: React.FC = ({ + addSuccessToast, + canUserCreateAndReadCases, + formatBytes, + formatNumber, + getGroupByFieldsOnClick, + ilmPhases, + openCreateCaseFlyout, + patternIndexNames, + patternRollups, + patterns, + theme, + updatePatternIndexNames, + updatePatternRollup, +}) => { + const [selectedIndex, setSelectedIndex] = useState(null); + + const onIndexSelected = useCallback(async ({ indexName, pattern }: SelectedIndex) => { + setSelectedIndex({ indexName, pattern }); + }, []); + + if (ilmPhases.length === 0) { + return ; + } + + return ( + <> + + + + + ); +}; + +DataQualityDetailsComponent.displayName = 'DataQualityDetailsComponent'; +export const DataQualityDetails = React.memo(DataQualityDetailsComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx new file mode 100644 index 0000000000000..ee4977ebe7858 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.test.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../../../helpers'; +import { alertIndexWithAllResults } from '../../../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { TestProviders } from '../../../../mock/test_providers/test_providers'; +import { PatternRollup } from '../../../../types'; +import { Props, IndicesDetails } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const patternIndexNames: Record = { + 'auditbeat-*': [ + '.ds-auditbeat-8.6.1-2023.02.07-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-custom-index-1', + ], + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], + 'packetbeat-*': [ + '.ds-packetbeat-8.5.3-2023.02.04-000001', + '.ds-packetbeat-8.6.1-2023.02.04-000001', + ], +}; + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: jest.fn(), + formatBytes, + formatNumber, + getGroupByFieldsOnClick: jest.fn(), + ilmPhases, + openCreateCaseFlyout: jest.fn(), + patternIndexNames, + patternRollups, + patterns, + selectedIndex: null, + setSelectedIndex: jest.fn(), + theme: DARK_THEME, + updatePatternIndexNames: jest.fn(), + updatePatternRollup: jest.fn(), +}; + +describe('IndicesDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + describe('rendering patterns', () => { + patterns.forEach((pattern) => { + test(`it renders the ${pattern} pattern`, () => { + expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + }); + }); + }); + + describe('rendering spacers', () => { + test('it renders the expected number of spacers', () => { + expect(screen.getAllByTestId('bodyPatternSpacer')).toHaveLength(patterns.length - 1); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx new file mode 100644 index 0000000000000..9b59a78430e1c --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/indices_details/index.tsx @@ -0,0 +1,110 @@ +/* + * 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 { + FlameElementEvent, + HeatmapElementEvent, + MetricElementEvent, + PartitionElementEvent, + Theme, + WordCloudElementEvent, + XYChartElementEvent, +} from '@elastic/charts'; +import { EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React from 'react'; + +import { Pattern } from '../../../pattern'; +import { PatternRollup, SelectedIndex } from '../../../../types'; + +export interface Props { + addSuccessToast: (toast: { title: string }) => void; + canUserCreateAndReadCases: () => boolean; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; + getGroupByFieldsOnClick: ( + elements: Array< + | FlameElementEvent + | HeatmapElementEvent + | MetricElementEvent + | PartitionElementEvent + | WordCloudElementEvent + | XYChartElementEvent + > + ) => { + groupByField0: string; + groupByField1: string; + }; + ilmPhases: string[]; + openCreateCaseFlyout: ({ + comments, + headerContent, + }: { + comments: string[]; + headerContent?: React.ReactNode; + }) => void; + patternIndexNames: Record; + patternRollups: Record; + patterns: string[]; + selectedIndex: SelectedIndex | null; + setSelectedIndex: (selectedIndex: SelectedIndex | null) => void; + theme: Theme; + updatePatternIndexNames: ({ + indexNames, + pattern, + }: { + indexNames: string[]; + pattern: string; + }) => void; + updatePatternRollup: (patternRollup: PatternRollup) => void; +} + +const IndicesDetailsComponent: React.FC = ({ + addSuccessToast, + canUserCreateAndReadCases, + formatBytes, + formatNumber, + getGroupByFieldsOnClick, + ilmPhases, + openCreateCaseFlyout, + patternIndexNames, + patternRollups, + patterns, + selectedIndex, + setSelectedIndex, + theme, + updatePatternIndexNames, + updatePatternRollup, +}) => ( +
+ {patterns.map((pattern, i) => ( + + + {patterns[i + 1] && } + + ))} +
+); + +IndicesDetailsComponent.displayName = 'IndicesDetailsComponent'; + +export const IndicesDetails = React.memo(IndicesDetailsComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.test.ts new file mode 100644 index 0000000000000..45e8f1ba1b4ad --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.test.ts @@ -0,0 +1,382 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { euiThemeVars } from '@kbn/ui-theme'; + +import { EMPTY_STAT } from '../../../../helpers'; +import { + DEFAULT_INDEX_COLOR, + getFillColor, + getFlattenedBuckets, + getGroupFromPath, + getLayersMultiDimensional, + getLegendItems, + getLegendItemsForPattern, + getPathToFlattenedBucketMap, + getPatternLegendItem, + getPatternSizeInBytes, +} from './helpers'; +import { alertIndexWithAllResults } from '../../../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { PatternRollup } from '../../../../types'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +/** a valid `PatternRollup` that has an undefined `sizeInBytes` */ +const noSizeInBytes: Record = { + 'valid-*': { + docsCount: 19127, + error: null, + ilmExplain: null, + ilmExplainPhaseCounts: { + hot: 1, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 2, + }, + indices: 3, + pattern: 'valid-*', + results: undefined, + sizeInBytes: undefined, // <-- + stats: null, + }, +}; + +describe('helpers', () => { + describe('getPatternSizeInBytes', () => { + test('it returns the expected size when the pattern exists in the rollup', () => { + const pattern = 'auditbeat-*'; + + expect(getPatternSizeInBytes({ pattern, patternRollups })).toEqual( + auditbeatWithAllResults.sizeInBytes + ); + }); + + test('it returns zero when the pattern exists in the rollup, but does not have a sizeInBytes', () => { + const pattern = 'valid-*'; + + expect(getPatternSizeInBytes({ pattern, patternRollups: noSizeInBytes })).toEqual(0); + }); + + test('it returns zero when the pattern does NOT exist in the rollup', () => { + const pattern = 'does-not-exist-*'; + + expect(getPatternSizeInBytes({ pattern, patternRollups })).toEqual(0); + }); + }); + + describe('getPatternLegendItem', () => { + test('it returns the expected legend item', () => { + const pattern = 'auditbeat-*'; + + expect(getPatternLegendItem({ pattern, patternRollups })).toEqual({ + color: null, + ilmPhase: null, + index: null, + pattern, + sizeInBytes: auditbeatWithAllResults.sizeInBytes, + }); + }); + }); + + describe('getLegendItemsForPattern', () => { + test('it returns the expected legend items', () => { + const pattern = 'auditbeat-*'; + const flattenedBuckets = getFlattenedBuckets({ + ilmPhases, + patternRollups, + }); + + expect(getLegendItemsForPattern({ pattern, flattenedBuckets })).toEqual([ + { + color: euiThemeVars.euiColorSuccess, + ilmPhase: 'hot', + index: '.ds-auditbeat-8.6.1-2023.02.07-000001', + pattern: 'auditbeat-*', + sizeInBytes: 18791790, + }, + { + color: euiThemeVars.euiColorDanger, + ilmPhase: 'unmanaged', + index: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 28409, + }, + { + color: euiThemeVars.euiColorDanger, + ilmPhase: 'unmanaged', + index: 'auditbeat-custom-empty-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 247, + }, + ]); + }); + }); + + describe('getLegendItems', () => { + test('it returns the expected legend items', () => { + const flattenedBuckets = getFlattenedBuckets({ + ilmPhases, + patternRollups, + }); + + expect(getLegendItems({ flattenedBuckets, patterns, patternRollups })).toEqual([ + { + color: null, + ilmPhase: null, + index: null, + pattern: '.alerts-security.alerts-default', + sizeInBytes: 29717961631, + }, + { + color: euiThemeVars.euiColorSuccess, + ilmPhase: 'hot', + index: '.internal.alerts-security.alerts-default-000001', + pattern: '.alerts-security.alerts-default', + sizeInBytes: 0, + }, + { color: null, ilmPhase: null, index: null, pattern: 'auditbeat-*', sizeInBytes: 18820446 }, + { + color: euiThemeVars.euiColorSuccess, + ilmPhase: 'hot', + index: '.ds-auditbeat-8.6.1-2023.02.07-000001', + pattern: 'auditbeat-*', + sizeInBytes: 18791790, + }, + { + color: euiThemeVars.euiColorDanger, + ilmPhase: 'unmanaged', + index: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 28409, + }, + { + color: euiThemeVars.euiColorDanger, + ilmPhase: 'unmanaged', + index: 'auditbeat-custom-empty-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 247, + }, + { + color: null, + ilmPhase: null, + index: null, + pattern: 'packetbeat-*', + sizeInBytes: 1096520898, + }, + { + color: euiThemeVars.euiColorPrimary, + ilmPhase: 'hot', + index: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'packetbeat-*', + sizeInBytes: 584326147, + }, + { + color: euiThemeVars.euiColorPrimary, + ilmPhase: 'hot', + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'packetbeat-*', + sizeInBytes: 512194751, + }, + ]); + }); + }); + + describe('getFlattenedBuckets', () => { + test('it returns the expected flattened buckets', () => { + expect( + getFlattenedBuckets({ + ilmPhases, + patternRollups, + }) + ).toEqual([ + { + ilmPhase: 'hot', + incompatible: 0, + indexName: '.internal.alerts-security.alerts-default-000001', + pattern: '.alerts-security.alerts-default', + sizeInBytes: 0, + }, + { + ilmPhase: 'hot', + incompatible: 0, + indexName: '.ds-auditbeat-8.6.1-2023.02.07-000001', + pattern: 'auditbeat-*', + sizeInBytes: 18791790, + }, + { + ilmPhase: 'unmanaged', + incompatible: 1, + indexName: 'auditbeat-custom-empty-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 247, + }, + { + ilmPhase: 'unmanaged', + incompatible: 3, + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + sizeInBytes: 28409, + }, + { + ilmPhase: 'hot', + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'packetbeat-*', + sizeInBytes: 512194751, + }, + { + ilmPhase: 'hot', + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'packetbeat-*', + sizeInBytes: 584326147, + }, + ]); + }); + }); + + describe('getFillColor', () => { + test('it returns success when `incompatible` is zero', () => { + const incompatible = 0; + + expect(getFillColor(incompatible)).toEqual(euiThemeVars.euiColorSuccess); + }); + + test('it returns danger when `incompatible` is greater than 0', () => { + const incompatible = 1; + + expect(getFillColor(incompatible)).toEqual(euiThemeVars.euiColorDanger); + }); + + test('it returns the default color when `incompatible` is undefined', () => { + const incompatible = undefined; + + expect(getFillColor(incompatible)).toEqual(DEFAULT_INDEX_COLOR); + }); + }); + + describe('getPathToFlattenedBucketMap', () => { + test('it returns the expected map', () => { + const flattenedBuckets = getFlattenedBuckets({ + ilmPhases, + patternRollups, + }); + + expect(getPathToFlattenedBucketMap(flattenedBuckets)).toEqual({ + '.alerts-security.alerts-default.internal.alerts-security.alerts-default-000001': { + pattern: '.alerts-security.alerts-default', + indexName: '.internal.alerts-security.alerts-default-000001', + ilmPhase: 'hot', + incompatible: 0, + sizeInBytes: 0, + }, + 'auditbeat-*.ds-auditbeat-8.6.1-2023.02.07-000001': { + pattern: 'auditbeat-*', + indexName: '.ds-auditbeat-8.6.1-2023.02.07-000001', + ilmPhase: 'hot', + incompatible: 0, + sizeInBytes: 18791790, + }, + 'auditbeat-*auditbeat-custom-empty-index-1': { + pattern: 'auditbeat-*', + indexName: 'auditbeat-custom-empty-index-1', + ilmPhase: 'unmanaged', + incompatible: 1, + sizeInBytes: 247, + }, + 'auditbeat-*auditbeat-custom-index-1': { + pattern: 'auditbeat-*', + indexName: 'auditbeat-custom-index-1', + ilmPhase: 'unmanaged', + incompatible: 3, + sizeInBytes: 28409, + }, + 'packetbeat-*.ds-packetbeat-8.6.1-2023.02.04-000001': { + pattern: 'packetbeat-*', + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + ilmPhase: 'hot', + sizeInBytes: 512194751, + }, + 'packetbeat-*.ds-packetbeat-8.5.3-2023.02.04-000001': { + pattern: 'packetbeat-*', + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + ilmPhase: 'hot', + sizeInBytes: 584326147, + }, + }); + }); + }); + + describe('getGroupFromPath', () => { + it('returns the expected group from the path', () => { + expect( + getGroupFromPath([ + { + index: 0, + value: '__null_small_multiples_key__', + }, + { + index: 0, + value: '__root_key__', + }, + { + index: 0, + value: 'auditbeat-*', + }, + { + index: 1, + value: 'auditbeat-custom-empty-index-1', + }, + ]) + ).toEqual('auditbeat-*'); + }); + + it('returns undefined when path is an empty array', () => { + expect(getGroupFromPath([])).toBeUndefined(); + }); + + it('returns undefined when path is an array with only one value', () => { + expect( + getGroupFromPath([{ index: 0, value: '__null_small_multiples_key__' }]) + ).toBeUndefined(); + }); + }); + + describe('getLayersMultiDimensional', () => { + const layer0FillColor = 'transparent'; + const flattenedBuckets = getFlattenedBuckets({ + ilmPhases, + patternRollups, + }); + const pathToFlattenedBucketMap = getPathToFlattenedBucketMap(flattenedBuckets); + + it('returns the expected number of layers', () => { + expect( + getLayersMultiDimensional({ formatBytes, layer0FillColor, pathToFlattenedBucketMap }).length + ).toEqual(2); + }); + + it('returns the expected fillLabel valueFormatter function', () => { + getLayersMultiDimensional({ formatBytes, layer0FillColor, pathToFlattenedBucketMap }).forEach( + (x) => expect(x.fillLabel.valueFormatter(123)).toEqual('123B') + ); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.ts new file mode 100644 index 0000000000000..09ed53402a89f --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/helpers.ts @@ -0,0 +1,223 @@ +/* + * 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 { Datum, Key, ArrayNode } from '@elastic/charts'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { orderBy } from 'lodash/fp'; + +import { getSizeInBytes } from '../../../../helpers'; +import { getIlmPhase } from '../../../pattern/helpers'; +import { PatternRollup } from '../../../../types'; + +export interface LegendItem { + color: string | null; + ilmPhase: string | null; + index: string | null; + pattern: string; + sizeInBytes: number; +} + +export interface FlattenedBucket { + ilmPhase: string | undefined; + incompatible: number | undefined; + indexName: string | undefined; + pattern: string; + sizeInBytes: number; +} + +export const getPatternSizeInBytes = ({ + pattern, + patternRollups, +}: { + pattern: string; + patternRollups: Record; +}): number => { + if (patternRollups[pattern] != null) { + return patternRollups[pattern].sizeInBytes ?? 0; + } else { + return 0; + } +}; + +export const getPatternLegendItem = ({ + pattern, + patternRollups, +}: { + pattern: string; + patternRollups: Record; +}): LegendItem => ({ + color: null, + ilmPhase: null, + index: null, + pattern, + sizeInBytes: getPatternSizeInBytes({ pattern, patternRollups }), +}); + +export const getLegendItemsForPattern = ({ + pattern, + flattenedBuckets, +}: { + pattern: string; + flattenedBuckets: FlattenedBucket[]; +}): LegendItem[] => + orderBy( + ['sizeInBytes'], + ['desc'], + flattenedBuckets + .filter((x) => x.pattern === pattern) + .map((flattenedBucket) => ({ + color: getFillColor(flattenedBucket.incompatible), + ilmPhase: flattenedBucket.ilmPhase ?? null, + index: flattenedBucket.indexName ?? null, + pattern: flattenedBucket.pattern, + sizeInBytes: flattenedBucket.sizeInBytes ?? 0, + })) + ); + +export const getLegendItems = ({ + patterns, + flattenedBuckets, + patternRollups, +}: { + patterns: string[]; + flattenedBuckets: FlattenedBucket[]; + patternRollups: Record; +}): LegendItem[] => + patterns.reduce( + (acc, pattern) => [ + ...acc, + getPatternLegendItem({ pattern, patternRollups }), + ...getLegendItemsForPattern({ pattern, flattenedBuckets }), + ], + [] + ); + +export const getFlattenedBuckets = ({ + ilmPhases, + patternRollups, +}: { + ilmPhases: string[]; + patternRollups: Record; +}): FlattenedBucket[] => + Object.values(patternRollups).reduce((acc, patternRollup) => { + // enables fast lookup of valid phase names: + const ilmPhasesMap = ilmPhases.reduce>( + (phasesMap, phase) => ({ ...phasesMap, [phase]: 0 }), + {} + ); + const { ilmExplain, pattern, results, stats } = patternRollup; + + if (ilmExplain != null && stats != null) { + return [ + ...acc, + ...Object.entries(stats).reduce( + (validStats, [indexName, indexStats]) => { + const ilmPhase = getIlmPhase(ilmExplain[indexName]); + const isSelectedPhase = ilmPhase != null && ilmPhasesMap[ilmPhase] != null; + + if (isSelectedPhase) { + const incompatible = + results != null && results[indexName] != null + ? results[indexName].incompatible + : undefined; + const sizeInBytes = getSizeInBytes({ indexName, stats }); + + return [ + ...validStats, + { + ilmPhase, + incompatible, + indexName, + pattern, + sizeInBytes, + }, + ]; + } else { + return validStats; + } + }, + [] + ), + ]; + } + + return acc; + }, []); + +const groupByRollup = (d: Datum) => d.pattern; // the treemap is grouped by this field + +export const DEFAULT_INDEX_COLOR = euiThemeVars.euiColorPrimary; + +export const getFillColor = (incompatible: number | undefined): string => { + if (incompatible === 0) { + return euiThemeVars.euiColorSuccess; + } else if (incompatible != null && incompatible > 0) { + return euiThemeVars.euiColorDanger; + } else { + return DEFAULT_INDEX_COLOR; + } +}; + +export const getPathToFlattenedBucketMap = ( + flattenedBuckets: FlattenedBucket[] +): Record => + flattenedBuckets.reduce>( + (acc, { pattern, indexName, ...remaining }) => ({ + ...acc, + [`${pattern}${indexName}`]: { pattern, indexName, ...remaining }, + }), + {} + ); + +/** + * Extracts the first group name from the data representing the second group + */ +export const getGroupFromPath = (path: ArrayNode['path']): string | undefined => { + const OFFSET_FROM_END = 2; // The offset from the end of the path array containing the group + const groupIndex = path.length - OFFSET_FROM_END; + return groupIndex > 0 ? path[groupIndex].value : undefined; +}; + +export const getLayersMultiDimensional = ({ + formatBytes, + layer0FillColor, + pathToFlattenedBucketMap, +}: { + formatBytes: (value: number | undefined) => string; + layer0FillColor: string; + pathToFlattenedBucketMap: Record; +}) => { + const valueFormatter = (d: number) => formatBytes(d); + + return [ + { + fillLabel: { + valueFormatter, + }, + groupByRollup, + nodeLabel: (ilmPhase: Datum) => ilmPhase, + shape: { + fillColor: layer0FillColor, + }, + }, + { + fillLabel: { + valueFormatter, + }, + groupByRollup: (d: Datum) => d.indexName, + nodeLabel: (indexName: Datum) => indexName, + shape: { + fillColor: (indexName: Key, sortIndex: number, node: Pick) => { + const pattern = getGroupFromPath(node.path) ?? ''; + const flattenedBucket = pathToFlattenedBucketMap[`${pattern}${indexName}`]; + + return getFillColor(flattenedBucket?.incompatible); + }, + }, + }, + ]; +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.test.tsx new file mode 100644 index 0000000000000..366fb487309c3 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../../../helpers'; +import { alertIndexWithAllResults } from '../../../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { TestProviders } from '../../../../mock/test_providers/test_providers'; +import { PatternRollup } from '../../../../types'; +import { Props, StorageDetails } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const onIndexSelected = jest.fn(); + +const defaultProps: Props = { + formatBytes, + ilmPhases, + onIndexSelected, + patternRollups, + patterns, + theme: DARK_THEME, +}; + +describe('StorageDetails', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the treemap', () => { + expect(screen.getByTestId('storageTreemap').querySelector('.echChart')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.tsx new file mode 100644 index 0000000000000..26340b31286fa --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/storage_details/index.tsx @@ -0,0 +1,58 @@ +/* + * 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 { Theme } from '@elastic/charts'; +import React, { useMemo } from 'react'; + +import { getFlattenedBuckets } from './helpers'; +import { StorageTreemap } from '../../../storage_treemap'; +import { DEFAULT_MAX_CHART_HEIGHT, StorageTreemapContainer } from '../../../tabs/styles'; +import { PatternRollup, SelectedIndex } from '../../../../types'; + +export interface Props { + formatBytes: (value: number | undefined) => string; + ilmPhases: string[]; + onIndexSelected: ({ indexName, pattern }: SelectedIndex) => void; + patternRollups: Record; + patterns: string[]; + theme: Theme; +} + +const StorageDetailsComponent: React.FC = ({ + formatBytes, + ilmPhases, + onIndexSelected, + patternRollups, + patterns, + theme, +}) => { + const flattenedBuckets = useMemo( + () => + getFlattenedBuckets({ + ilmPhases, + patternRollups, + }), + [ilmPhases, patternRollups] + ); + + return ( + + + + ); +}; + +StorageDetailsComponent.displayName = 'StorageDetailsComponent'; +export const StorageDetails = React.memo(StorageDetailsComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/translations.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/translations.ts similarity index 51% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/translations.ts rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/translations.ts index b5f77c455c1a2..6b8ffed70f8c9 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/translations.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/data_quality_details/translations.ts @@ -7,9 +7,16 @@ import { i18n } from '@kbn/i18n'; -export const TAKE_ACTION = i18n.translate( - 'ecsDataQualityDashboard.takeActionMenu.takeActionButton', +export const INDICES_TAB_TITLE = i18n.translate( + 'ecsDataQualityDashboard.body.tabs.indicesTabTitle', { - defaultMessage: 'Take action', + defaultMessage: 'Indices', + } +); + +export const STORAGE_TAB_TITLE = i18n.translate( + 'ecsDataQualityDashboard.body.tabs.storageTabTitle', + { + defaultMessage: 'Storage', } ); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx new file mode 100644 index 0000000000000..0f27f307f7913 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../helpers'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { Body } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; + +describe('IndexInvalidValues', () => { + test('it renders the data quality summary', () => { + render( + + + + ); + + expect(screen.getByTestId('dataQualitySummary')).toBeInTheDocument(); + }); + + describe('patterns', () => { + const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'logs-*', 'packetbeat-*']; + + patterns.forEach((pattern) => { + test(`it renders the '${pattern}' pattern`, () => { + render( + + + + ); + + expect(screen.getByTestId(`${pattern}PatternPanel`)).toBeInTheDocument(); + }); + }); + + test('it renders the expected number of spacers', async () => { + render( + + + + ); + + const items = await screen.findAllByTestId('bodyPatternSpacer'); + expect(items).toHaveLength(patterns.length - 1); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.tsx index 87aed178043cc..69de3b8c110e5 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/body/index.tsx @@ -17,14 +17,15 @@ import type { import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React from 'react'; +import { DataQualityDetails } from './data_quality_details'; import { DataQualitySummary } from '../data_quality_summary'; -import { Pattern } from '../pattern'; import { useResultsRollup } from '../../use_results_rollup'; interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; getGroupByFieldsOnClick: ( elements: Array< | FlameElementEvent @@ -55,7 +56,8 @@ interface Props { const BodyComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, getGroupByFieldsOnClick, ilmPhases, lastChecked, @@ -72,6 +74,7 @@ const BodyComponent: React.FC = ({ totalIncompatible, totalIndices, totalIndicesChecked, + totalSizeInBytes, updatePatternIndexNames, updatePatternRollup, } = useResultsRollup({ ilmPhases, patterns }); @@ -82,7 +85,8 @@ const BodyComponent: React.FC = ({ = ({ totalIncompatible={totalIncompatible} totalIndices={totalIndices} totalIndicesChecked={totalIndicesChecked} + totalSizeInBytes={totalSizeInBytes} onCheckCompleted={onCheckCompleted} /> - {patterns.map((pattern, i) => ( - - - {i !== patterns.length - 1 ? : null} - - ))} + + +
); }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.test.tsx new file mode 100644 index 0000000000000..5085db2a93e51 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.test.tsx @@ -0,0 +1,212 @@ +/* + * 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 { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { IndexToCheck } from '../../../types'; +import { CheckStatus, EMPTY_LAST_CHECKED_DATE } from '.'; + +const indexToCheck: IndexToCheck = { + pattern: 'auditbeat-*', + indexName: '.ds-auditbeat-8.6.1-2023.02.13-000001', +}; +const checkAllIndiciesChecked = 2; +const checkAllTotalIndiciesToCheck = 3; + +describe('CheckStatus', () => { + describe('when `indexToCheck` is not null', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders progress with the expected max value', () => { + expect(screen.getByTestId('progress')).toHaveAttribute( + 'max', + String(checkAllTotalIndiciesToCheck) + ); + }); + + test('it renders progress with the expected current value', () => { + expect(screen.getByTestId('progress')).toHaveAttribute( + 'value', + String(checkAllIndiciesChecked) + ); + }); + + test('it renders the expected "checking " message', () => { + expect(screen.getByTestId('checking')).toHaveTextContent( + `Checking ${indexToCheck.indexName}` + ); + }); + + test('it does NOT render the last checked message', () => { + expect(screen.queryByTestId('lastChecked')).not.toBeInTheDocument(); + }); + }); + + describe('when `indexToCheck` is null', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it does NOT render the progress bar', () => { + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + + test('it does NOT render the "checking " message', () => { + expect(screen.queryByTestId('checking')).not.toBeInTheDocument(); + }); + + test('it renders the expected last checked message', () => { + expect(screen.getByTestId('lastChecked')).toHaveTextContent(EMPTY_LAST_CHECKED_DATE); + }); + }); + + test('it renders the errors popover when errors have occurred', () => { + const errorSummary = [ + { + pattern: '.alerts-security.alerts-default', + indexName: null, + error: 'Error loading stats: Error: Forbidden', + }, + ]; + + render( + + + + ); + + expect(screen.getByTestId('errorsPopover')).toBeInTheDocument(); + }); + + test('it does NOT render the errors popover when errors have NOT occurred', () => { + render( + + + + ); + + expect(screen.queryByTestId('errorsPopover')).not.toBeInTheDocument(); + }); + + test('it invokes the `setLastChecked` callback when indexToCheck is not null', () => { + jest.useFakeTimers(); + const date = '2023-03-28T22:27:28.159Z'; + jest.setSystemTime(new Date(date)); + + const setLastChecked = jest.fn(); + + render( + + + + ); + + expect(setLastChecked).toBeCalledWith(date); + jest.useRealTimers(); + }); + + test('it updates the formatted date', async () => { + jest.useFakeTimers(); + const date = '2023-03-28T23:27:28.159Z'; + jest.setSystemTime(new Date(date)); + + const { rerender } = render( + + + + ); + + // re-render with an updated `lastChecked` + const lastChecked = '2023-03-28T22:27:28.159Z'; + + act(() => { + jest.advanceTimersByTime(1000 * 61); + }); + + rerender( + + + + ); + + act(() => { + // once again, advance time + jest.advanceTimersByTime(1000 * 61); + }); + + expect(await screen.getByTestId('lastChecked')).toHaveTextContent('Last checked: an hour ago'); + jest.useRealTimers(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.tsx index 3ff69274ba06e..9245b0adee84c 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/check_status/index.tsx @@ -60,30 +60,31 @@ const CheckStatusComponent: React.FC = ({ }, [lastChecked]); return ( - + {indexToCheck != null && ( <> - + + {i18n.CHECKING(indexToCheck.indexName)} + - - {i18n.CHECKING(indexToCheck.indexName)} - + )} {indexToCheck == null && ( - + {i18n.LAST_CHECKED} {': '} {formattedDate} diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.test.tsx new file mode 100644 index 0000000000000..064ec92a1ca81 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.test.tsx @@ -0,0 +1,97 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { ErrorsPopover } from '.'; + +const mockCopyToClipboard = jest.fn((value) => true); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + copyToClipboard: (value: string) => mockCopyToClipboard(value), + }; +}); + +const errorSummary = [ + { + pattern: '.alerts-security.alerts-default', + indexName: null, + error: 'Error loading stats: Error: Forbidden', + }, +]; + +describe('ErrorsPopover', () => { + beforeEach(() => { + document.execCommand = jest.fn(); + }); + + test('it disables the view errors button when `errorSummary` is empty', () => { + render( + + + + ); + + expect(screen.getByTestId('viewErrors')).toBeDisabled(); + }); + + test('it enables the view errors button when `errorSummary` is NOT empty', () => { + render( + + + + ); + + expect(screen.getByTestId('viewErrors')).not.toBeDisabled(); + }); + + describe('popover content', () => { + const addSuccessToast = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + render( + + + + ); + + const viewErrorsButton = screen.getByTestId('viewErrors'); + + act(() => { + userEvent.click(viewErrorsButton); + }); + }); + + test('it renders the expected callout content', () => { + expect(screen.getByTestId('callout')).toHaveTextContent( + "ErrorsSome indices were not checked for Data QualityErrors may occur when pattern or index metadata is temporarily unavailable, or because you don't have the privileges required for accessThe following privileges are required to check an index:monitor or manageview_index_metadatareadCopy to clipboard" + ); + }); + + test('it invokes `addSuccessToast` when the copy button is clicked', () => { + const copyToClipboardButton = screen.getByTestId('copyToClipboard'); + act(() => { + userEvent.click(copyToClipboardButton, undefined, { skipPointerEventsCheck: true }); + }); + + expect(addSuccessToast).toBeCalledWith({ title: 'Copied errors to the clipboard' }); + }); + + test('it renders the expected error summary text in the errors viewer', () => { + expect(screen.getByTestId('errorsViewer').textContent?.includes(errorSummary[0].error)).toBe( + true + ); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.tsx index b9e0fc61ec545..8f80e3fa3cab5 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_popover/index.tsx @@ -60,6 +60,7 @@ const ErrorsPopoverComponent: React.FC = ({ addSuccessToast, errorSummary () => ( = ({ addSuccessToast, errorSummary - +

{i18n.ERRORS_CALLOUT_SUMMARY}

{i18n.ERRORS_MAY_OCCUR}

@@ -96,7 +98,13 @@ const ErrorsPopoverComponent: React.FC = ({ addSuccessToast, errorSummary - + {i18n.COPY_TO_CLIPBOARD}
diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.test.tsx new file mode 100644 index 0000000000000..1954e92ae5fc7 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.test.tsx @@ -0,0 +1,122 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { omit } from 'lodash/fp'; +import React from 'react'; + +import { getErrorsViewerTableColumns } from './helpers'; +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { ErrorSummary } from '../../../types'; + +const errorSummary: ErrorSummary[] = [ + { + pattern: '.alerts-security.alerts-default', + indexName: null, + error: 'Error loading stats: Error: Forbidden', + }, + { + error: + 'Error: Error loading unallowed values for index auditbeat-7.2.1-2023.02.13-000001: Forbidden', + indexName: 'auditbeat-7.2.1-2023.02.13-000001', + pattern: 'auditbeat-*', + }, +]; + +const noIndexName: ErrorSummary = errorSummary[0]; // <-- indexName: null +const hasIndexName: ErrorSummary = errorSummary[1]; + +describe('helpers', () => { + describe('getCommonTableColumns', () => { + test('it returns the expected column configuration', () => { + const columns = getErrorsViewerTableColumns().map((x) => omit('render', x)); + + expect(columns).toEqual([ + { + field: 'pattern', + name: 'Pattern', + sortable: true, + truncateText: false, + width: '25%', + }, + { + field: 'indexName', + name: 'Index', + sortable: false, + truncateText: false, + width: '25%', + }, + { + field: 'error', + name: 'Error', + sortable: false, + truncateText: false, + width: '50%', + }, + ]); + }); + + describe('indexName column render()', () => { + describe('when the `ErrorSummary` has an `indexName`', () => { + beforeEach(() => { + const columns = getErrorsViewerTableColumns(); + const indexNameRender = columns[1].render; + + render( + + {indexNameRender != null && indexNameRender(hasIndexName.indexName, hasIndexName)} + + ); + }); + + test('it renders the expected `indexName`', () => { + expect(screen.getByTestId('indexName')).toHaveTextContent(String(hasIndexName.indexName)); + }); + + test('it does NOT render the placeholder', () => { + expect(screen.queryByTestId('emptyPlaceholder')).not.toBeInTheDocument(); + }); + }); + + describe('when the `ErrorSummary` does NOT have an `indexName`', () => { + beforeEach(() => { + const columns = getErrorsViewerTableColumns(); + const indexNameRender = columns[1].render; + + render( + + {indexNameRender != null && indexNameRender(noIndexName.indexName, noIndexName)} + + ); + }); + + test('it does NOT render `indexName`', () => { + expect(screen.queryByTestId('indexName')).not.toBeInTheDocument(); + }); + + test('it renders the placeholder', () => { + expect(screen.getByTestId('emptyPlaceholder')).toBeInTheDocument(); + }); + }); + }); + + describe('indexName error render()', () => { + test('it renders the expected `error`', () => { + const columns = getErrorsViewerTableColumns(); + const indexNameRender = columns[2].render; + + render( + + {indexNameRender != null && indexNameRender(hasIndexName.error, hasIndexName)} + + ); + + expect(screen.getByTestId('error')).toHaveTextContent(hasIndexName.error); + }); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.tsx index caac710cf8d13..35a4a74cca875 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/helpers.tsx @@ -28,7 +28,12 @@ export const getErrorsViewerTableColumns = (): Array (indexName != null && indexName !== '' ? indexName : EMPTY_PLACEHOLDER), + render: (indexName: string | null) => + indexName != null && indexName !== '' ? ( + {indexName} + ) : ( + {EMPTY_PLACEHOLDER} + ), sortable: false, truncateText: false, width: '25%', @@ -36,7 +41,7 @@ export const getErrorsViewerTableColumns = (): Array {errorText}, + render: (errorText) => {errorText}, sortable: false, truncateText: false, width: '50%', diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.test.tsx new file mode 100644 index 0000000000000..a1b6346eb2b8d --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { ERROR, INDEX, PATTERN } from './translations'; +import { ErrorSummary } from '../../../types'; +import { ErrorsViewer } from '.'; + +interface ExpectedColumns { + id: string; + expected: string; +} + +const errorSummary: ErrorSummary[] = [ + { + pattern: '.alerts-security.alerts-default', + indexName: null, + error: 'Error loading stats: Error: Forbidden', + }, + { + error: + 'Error: Error loading unallowed values for index auditbeat-7.2.1-2023.02.13-000001: Forbidden', + indexName: 'auditbeat-7.2.1-2023.02.13-000001', + pattern: 'auditbeat-*', + }, +]; + +describe('ErrorsViewer', () => { + const expectedColumns: ExpectedColumns[] = [ + { + id: 'pattern', + expected: PATTERN, + }, + { + id: 'indexName', + expected: INDEX, + }, + { + id: 'error', + expected: ERROR, + }, + ]; + + expectedColumns.forEach(({ id, expected }, i) => { + test(`it renders the expected '${id}' column header`, () => { + render( + + + + ); + + expect(screen.getByTestId(`tableHeaderCell_${id}_${i}`)).toHaveTextContent(expected); + }); + }); + + test(`it renders the expected the errors`, () => { + render( + + + + ); + + expect( + screen + .getAllByTestId('error') + .map((x) => x.textContent ?? '') + .reduce((acc, x) => acc.concat(x), '') + ).toEqual(`${errorSummary[0].error}${errorSummary[1].error}`); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.tsx index a40094c96b399..2336abe79c651 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/errors_viewer/index.tsx @@ -29,7 +29,7 @@ const ErrorsViewerComponent: React.FC = ({ errorSummary }) => { const columns = useMemo(() => getErrorsViewerTableColumns(), []); return ( - + + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const patternIndexNames: Record = { + 'auditbeat-*': [ + '.ds-auditbeat-8.6.1-2023.02.07-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-custom-index-1', + ], + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], + 'packetbeat-*': [ + '.ds-packetbeat-8.5.3-2023.02.04-000001', + '.ds-packetbeat-8.6.1-2023.02.04-000001', + ], +}; + +const lastChecked = '2023-03-28T23:27:28.159Z'; + +const totalDocsCount = getTotalDocsCount(patternRollups); +const totalIncompatible = getTotalIncompatible(patternRollups); +const totalIndices = getTotalIndices(patternRollups); +const totalIndicesChecked = getTotalIndicesChecked(patternRollups); +const totalSizeInBytes = getTotalSizeInBytes(patternRollups); + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: jest.fn(), + formatBytes, + formatNumber, + ilmPhases, + lastChecked, + openCreateCaseFlyout: jest.fn(), + patternIndexNames, + patternRollups, + patterns, + setLastChecked: jest.fn(), + totalDocsCount, + totalIncompatible, + totalIndices, + totalIndicesChecked, + totalSizeInBytes, + onCheckCompleted: jest.fn(), +}; + +describe('DataQualitySummary', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the summary actions', () => { + expect(screen.getByTestId('summaryActions')).toBeInTheDocument(); + }); + + test('it renders the stats rollup', () => { + expect(screen.getByTestId('statsRollup')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/index.tsx index c6874d861ddb8..d3f9ad9d23303 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/index.tsx @@ -5,15 +5,14 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { CheckStatus } from './check_status'; +import { getErrorSummaries } from '../../helpers'; import { StatsRollup } from '../pattern/pattern_summary/stats_rollup'; import { SummaryActions } from './summary_actions'; -import type { IndexToCheck, OnCheckCompleted, PatternRollup } from '../../types'; -import { getErrorSummaries } from '../../helpers'; +import type { OnCheckCompleted, PatternRollup } from '../../types'; const MAX_SUMMARY_ACTIONS_CONTAINER_WIDTH = 400; const MIN_SUMMARY_ACTIONS_CONTAINER_WIDTH = 235; @@ -24,10 +23,11 @@ const SummaryActionsContainerFlexItem = styled(EuiFlexItem)` padding-right: ${({ theme }) => theme.eui.euiSizeXL}; `; -interface Props { +export interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmPhases: string[]; lastChecked: string; openCreateCaseFlyout: ({ @@ -45,13 +45,15 @@ interface Props { totalIncompatible: number | undefined; totalIndices: number | undefined; totalIndicesChecked: number | undefined; + totalSizeInBytes: number | undefined; onCheckCompleted: OnCheckCompleted; } const DataQualitySummaryComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, ilmPhases, lastChecked, openCreateCaseFlyout, @@ -63,64 +65,46 @@ const DataQualitySummaryComponent: React.FC = ({ totalIncompatible, totalIndices, totalIndicesChecked, + totalSizeInBytes, onCheckCompleted, }) => { - const [indexToCheck, setIndexToCheck] = useState(null); - - const [checkAllIndiciesChecked, setCheckAllIndiciesChecked] = useState(0); - const [checkAllTotalIndiciesToCheck, setCheckAllTotalIndiciesToCheck] = useState(0); - - const incrementCheckAllIndiciesChecked = useCallback(() => { - setCheckAllIndiciesChecked((current) => current + 1); - }, []); - const errorSummary = useMemo(() => getErrorSummaries(patternRollups), [patternRollups]); return ( - + - - - - diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.test.tsx new file mode 100644 index 0000000000000..02b04225a544e --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.test.tsx @@ -0,0 +1,104 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../mock/test_providers/test_providers'; +import { Props, Actions } from '.'; + +const mockCopyToClipboard = jest.fn((value) => true); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + copyToClipboard: (value: string) => mockCopyToClipboard(value), + }; +}); + +const ilmPhases = ['hot', 'warm', 'unmanaged']; + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: () => true, + getMarkdownComments: () => [], + ilmPhases, + openCreateCaseFlyout: jest.fn(), +}; + +describe('Actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when the action buttons are clicked', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it invokes openCreateCaseFlyout when the add to new case button is clicked', () => { + const button = screen.getByTestId('addToNewCase'); + + userEvent.click(button); + + expect(defaultProps.openCreateCaseFlyout).toBeCalled(); + }); + + test('it invokes addSuccessToast when the copy to clipboard button is clicked', () => { + const button = screen.getByTestId('copyToClipboard'); + + userEvent.click(button); + + expect(defaultProps.addSuccessToast).toBeCalledWith({ + title: 'Copied results to the clipboard', + }); + }); + }); + + test('it disables the add to new case button when the user cannot create cases', () => { + const canUserCreateAndReadCases = () => false; + + render( + + + + ); + + const button = screen.getByTestId('addToNewCase'); + + expect(button).toBeDisabled(); + }); + + test('it disables the add to new case button when `ilmPhases` is empty', () => { + render( + + + + ); + + const button = screen.getByTestId('addToNewCase'); + + expect(button).toBeDisabled(); + }); + + test('it disables the copy to clipboard button when `ilmPhases` is empty', () => { + render( + + + + ); + + const button = screen.getByTestId('copyToClipboard'); + + expect(button).toBeDisabled(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.tsx new file mode 100644 index 0000000000000..549f420c3fa9d --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/actions/index.tsx @@ -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 { copyToClipboard, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; + +import { + ADD_TO_NEW_CASE, + COPIED_RESULTS_TOAST_TITLE, + COPY_TO_CLIPBOARD, +} from '../../../../translations'; +import { useAddToNewCase } from '../../../../use_add_to_new_case'; + +export interface Props { + addSuccessToast: (toast: { title: string }) => void; + canUserCreateAndReadCases: () => boolean; + getMarkdownComments: () => string[]; + ilmPhases: string[]; + openCreateCaseFlyout: ({ + comments, + headerContent, + }: { + comments: string[]; + headerContent?: React.ReactNode; + }) => void; +} + +const ActionsComponent: React.FC = ({ + addSuccessToast, + canUserCreateAndReadCases, + getMarkdownComments, + ilmPhases, + openCreateCaseFlyout, +}) => { + const { disabled: addToNewCaseDisabled, onAddToNewCase } = useAddToNewCase({ + canUserCreateAndReadCases, + openCreateCaseFlyout, + }); + + const onClickAddToCase = useCallback( + () => onAddToNewCase([getMarkdownComments().join('\n')]), + [getMarkdownComments, onAddToNewCase] + ); + + const onCopy = useCallback(() => { + const markdown = getMarkdownComments().join('\n'); + copyToClipboard(markdown); + + addSuccessToast({ + title: COPIED_RESULTS_TOAST_TITLE, + }); + }, [addSuccessToast, getMarkdownComments]); + + const addToNewCaseContextMenuOnClick = useCallback(() => { + onClickAddToCase(); + }, [onClickAddToCase]); + + const disableAll = ilmPhases.length === 0; + + return ( + + + + {ADD_TO_NEW_CASE} + + + + + + {COPY_TO_CLIPBOARD} + + + + ); +}; + +ActionsComponent.displayName = 'ActionsComponent'; + +export const Actions = React.memo(ActionsComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts new file mode 100644 index 0000000000000..fd457193a9c6f --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.test.ts @@ -0,0 +1,338 @@ +/* + * 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 { EcsFlat, EcsVersion } from '@kbn/ecs'; + +import { checkIndex, EMPTY_PARTITIONED_FIELD_METADATA } from './check_index'; +import { EMPTY_STAT } from '../../../../helpers'; +import { mockMappingsResponse } from '../../../../mock/mappings_response/mock_mappings_response'; +import { mockUnallowedValuesResponse } from '../../../../mock/unallowed_values/mock_unallowed_values'; +import { EcsMetadata, UnallowedValueRequestItem } from '../../../../types'; + +const ecsMetadata = EcsFlat as unknown as Record; + +let mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + new Promise((resolve) => { + resolve(mockMappingsResponse); // happy path + }) +); + +jest.mock('../../../../use_mappings/helpers', () => ({ + fetchMappings: ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + mockFetchMappings({ + abortController, + patternOrIndexName, + }), +})); + +const mockFetchUnallowedValues = jest.fn( + ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => new Promise((resolve) => resolve(mockUnallowedValuesResponse)) +); + +jest.mock('../../../../use_unallowed_values/helpers', () => { + const original = jest.requireActual('../../../../use_unallowed_values/helpers'); + + return { + ...original, + fetchUnallowedValues: ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => + mockFetchUnallowedValues({ + abortController, + indexName, + requestItems, + }), + }; +}); + +describe('checkIndex', () => { + const defaultBytesFormat = '0,0.[0]b'; + const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + + const defaultNumberFormat = '0,0.[000]'; + const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + + const indexName = 'auditbeat-custom-index-1'; + const pattern = 'auditbeat-*'; + + describe('happy path', () => { + const onCheckCompleted = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + await checkIndex({ + abortController: new AbortController(), + ecsMetadata, + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + }); + + test('it invokes onCheckCompleted with a null `error`', () => { + expect(onCheckCompleted.mock.calls[0][0].error).toBeNull(); + }); + + test('it invokes onCheckCompleted with the expected `indexName`', () => { + expect(onCheckCompleted.mock.calls[0][0].indexName).toEqual(indexName); + }); + + test('it invokes onCheckCompleted with the non-default `partitionedFieldMetadata`', () => { + expect(onCheckCompleted.mock.calls[0][0].partitionedFieldMetadata).not.toEqual( + EMPTY_PARTITIONED_FIELD_METADATA + ); + }); + + test('it invokes onCheckCompleted with the expected`pattern`', () => { + expect(onCheckCompleted.mock.calls[0][0].pattern).toEqual(pattern); + }); + + test('it invokes onCheckCompleted with the expected `version`', () => { + expect(onCheckCompleted.mock.calls[0][0].version).toEqual(EcsVersion); + }); + }); + + describe('happy path, when the signal is aborted', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it does NOT invoke onCheckCompleted', async () => { + const onCheckCompleted = jest.fn(); + + const abortController = new AbortController(); + abortController.abort(); + + await checkIndex({ + abortController, + ecsMetadata, + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + + expect(onCheckCompleted).not.toBeCalled(); + }); + }); + + describe('when `ecsMetadata` is null', () => { + const onCheckCompleted = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + await checkIndex({ + abortController: new AbortController(), + ecsMetadata: null, // <-- + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + }); + + test('it invokes onCheckCompleted with a null `error`', () => { + expect(onCheckCompleted.mock.calls[0][0].error).toBeNull(); + }); + + test('it invokes onCheckCompleted with the expected `indexName`', () => { + expect(onCheckCompleted.mock.calls[0][0].indexName).toEqual(indexName); + }); + + test('it invokes onCheckCompleted with the default `partitionedFieldMetadata`', () => { + expect(onCheckCompleted.mock.calls[0][0].partitionedFieldMetadata).toEqual( + EMPTY_PARTITIONED_FIELD_METADATA + ); + }); + + test('it invokes onCheckCompleted with the expected `pattern`', () => { + expect(onCheckCompleted.mock.calls[0][0].pattern).toEqual(pattern); + }); + + test('it invokes onCheckCompleted with the expected `version`', () => { + expect(onCheckCompleted.mock.calls[0][0].version).toEqual(EcsVersion); + }); + }); + + describe('when an error occurs', () => { + const onCheckCompleted = jest.fn(); + const error = 'simulated fetch mappings error'; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => new Promise((_, reject) => reject(new Error(error))) + ); + + await checkIndex({ + abortController: new AbortController(), + ecsMetadata, + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + }); + + test('it invokes onCheckCompleted with the expected `error`', () => { + expect(onCheckCompleted.mock.calls[0][0].error).toEqual(`Error: ${error}`); + }); + + test('it invokes onCheckCompleted with the expected `indexName`', () => { + expect(onCheckCompleted.mock.calls[0][0].indexName).toEqual(indexName); + }); + + test('it invokes onCheckCompleted with null `partitionedFieldMetadata`', () => { + expect(onCheckCompleted.mock.calls[0][0].partitionedFieldMetadata).toBeNull(); + }); + + test('it invokes onCheckCompleted with the expected `pattern`', () => { + expect(onCheckCompleted.mock.calls[0][0].pattern).toEqual(pattern); + }); + + test('it invokes onCheckCompleted with the expected `version`', () => { + expect(onCheckCompleted.mock.calls[0][0].version).toEqual(EcsVersion); + }); + }); + + describe('when an error occurs, but the error does not have a toString', () => { + const onCheckCompleted = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + // eslint-disable-next-line prefer-promise-reject-errors + }) => new Promise((_, reject) => reject(undefined)) + ); + + await checkIndex({ + abortController: new AbortController(), + ecsMetadata, + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + }); + + test('it invokes onCheckCompleted with the fallback `error`', () => { + expect(onCheckCompleted.mock.calls[0][0].error).toEqual( + `An error occurred checking index ${indexName}` + ); + }); + + test('it invokes onCheckCompleted with the expected `indexName`', () => { + expect(onCheckCompleted.mock.calls[0][0].indexName).toEqual(indexName); + }); + + test('it invokes onCheckCompleted with null `partitionedFieldMetadata`', () => { + expect(onCheckCompleted.mock.calls[0][0].partitionedFieldMetadata).toBeNull(); + }); + + test('it invokes onCheckCompleted with the expected `pattern`', () => { + expect(onCheckCompleted.mock.calls[0][0].pattern).toEqual(pattern); + }); + + test('it invokes onCheckCompleted with the expected `version`', () => { + expect(onCheckCompleted.mock.calls[0][0].version).toEqual(EcsVersion); + }); + }); + + describe('when an error occurs, and the signal is aborted', () => { + const onCheckCompleted = jest.fn(); + const abortController = new AbortController(); + abortController.abort(); + + const error = 'simulated fetch mappings error'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it does NOT invoke onCheckCompleted', async () => { + mockFetchMappings = jest.fn( + ({ + // eslint-disable-next-line @typescript-eslint/no-shadow + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => new Promise((_, reject) => reject(new Error(error))) + ); + + await checkIndex({ + abortController, + ecsMetadata, + formatBytes, + formatNumber, + indexName, + onCheckCompleted, + pattern, + version: EcsVersion, + }); + + expect(onCheckCompleted).not.toBeCalled(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts index cd1ce0940b391..c65b3f7559071 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/check_index.ts @@ -15,7 +15,7 @@ import type { EcsMetadata, OnCheckCompleted, PartitionedFieldMetadata } from '.. import { fetchMappings } from '../../../../use_mappings/helpers'; import { fetchUnallowedValues, getUnallowedValues } from '../../../../use_unallowed_values/helpers'; -const EMPTY_PARTITIONED_FIELD_METADATA: PartitionedFieldMetadata = { +export const EMPTY_PARTITIONED_FIELD_METADATA: PartitionedFieldMetadata = { all: [], custom: [], ecsCompliant: [], @@ -25,6 +25,7 @@ const EMPTY_PARTITIONED_FIELD_METADATA: PartitionedFieldMetadata = { export async function checkIndex({ abortController, ecsMetadata, + formatBytes, formatNumber, indexName, onCheckCompleted, @@ -33,6 +34,7 @@ export async function checkIndex({ }: { abortController: AbortController; ecsMetadata: Record | null; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; indexName: string; onCheckCompleted: OnCheckCompleted; @@ -74,6 +76,7 @@ export async function checkIndex({ if (!abortController.signal.aborted) { onCheckCompleted({ error: null, + formatBytes, formatNumber, indexName, partitionedFieldMetadata, @@ -84,10 +87,8 @@ export async function checkIndex({ } catch (error) { if (!abortController.signal.aborted) { onCheckCompleted({ - error: - error.toString() != null - ? error.toString() - : i18n.AN_ERROR_OCCURRED_CHECKING_INDEX(indexName), + error: error != null ? error.toString() : i18n.AN_ERROR_OCCURRED_CHECKING_INDEX(indexName), + formatBytes, formatNumber, indexName, partitionedFieldMetadata: null, diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.test.ts new file mode 100644 index 0000000000000..5f96cfa9953a6 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { getAllIndicesToCheck, getIndexDocsCountFromRollup, getIndexToCheck } from './helpers'; +import { mockPacketbeatPatternRollup } from '../../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; + +const patternIndexNames: Record = { + 'packetbeat-*': [ + '.ds-packetbeat-8.6.1-2023.02.04-000001', + '.ds-packetbeat-8.5.3-2023.02.04-000001', + ], + 'auditbeat-*': [ + 'auditbeat-7.17.9-2023.02.13-000001', + 'auditbeat-custom-index-1', + '.ds-auditbeat-8.6.1-2023.02.13-000001', + ], + 'logs-*': [ + '.ds-logs-endpoint.alerts-default-2023.02.24-000001', + '.ds-logs-endpoint.events.process-default-2023.02.24-000001', + ], + 'remote:*': [], + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], +}; + +describe('helpers', () => { + describe('getIndexToCheck', () => { + test('it returns the expected `IndexToCheck`', () => { + expect( + getIndexToCheck({ + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + }) + ).toEqual({ + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + }); + }); + }); + + describe('getAllIndicesToCheck', () => { + test('it returns the sorted collection of `IndexToCheck`', () => { + expect(getAllIndicesToCheck(patternIndexNames)).toEqual([ + { + indexName: '.internal.alerts-security.alerts-default-000001', + pattern: '.alerts-security.alerts-default', + }, + { + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + }, + { + indexName: 'auditbeat-7.17.9-2023.02.13-000001', + pattern: 'auditbeat-*', + }, + { + indexName: '.ds-auditbeat-8.6.1-2023.02.13-000001', + pattern: 'auditbeat-*', + }, + { + indexName: '.ds-logs-endpoint.events.process-default-2023.02.24-000001', + pattern: 'logs-*', + }, + { + indexName: '.ds-logs-endpoint.alerts-default-2023.02.24-000001', + pattern: 'logs-*', + }, + { + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'packetbeat-*', + }, + { + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'packetbeat-*', + }, + ]); + }); + }); + + describe('getIndexDocsCountFromRollup', () => { + test('it returns the expected count when the `patternRollup` has `stats`', () => { + expect( + getIndexDocsCountFromRollup({ + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + patternRollup: mockPacketbeatPatternRollup, + }) + ).toEqual(1628343); + }); + + test('it returns zero when the `patternRollup` `stats` is null', () => { + const patternRollup = { + ...mockPacketbeatPatternRollup, + stats: null, // <-- + }; + + expect( + getIndexDocsCountFromRollup({ + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + patternRollup, + }) + ).toEqual(0); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.ts index 4d07d4826f521..30f314c73ea3b 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/helpers.ts @@ -6,7 +6,7 @@ */ import type { IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types'; -import { sortBy } from 'lodash/fp'; +import { orderBy } from 'lodash/fp'; import { getDocsCount } from '../../../../helpers'; import type { IndexToCheck, PatternRollup } from '../../../../types'; @@ -34,14 +34,14 @@ export const getAllIndicesToCheck = ( return a.localeCompare(b); }); - // return all `IndexToCheck` sorted first by pattern A-Z, and then by `docsCount` within the pattern + // return all `IndexToCheck` sorted first by pattern A-Z: return sortedPatterns.reduce((acc, pattern) => { - const indexNames = patternIndexNames[pattern] ?? []; + const indexNames = patternIndexNames[pattern]; const indicesToCheck = indexNames.map((indexName) => getIndexToCheck({ indexName, pattern }) ); - const sortedIndicesToCheck = sortBy('indexName', indicesToCheck).reverse(); + const sortedIndicesToCheck = orderBy(['indexName'], ['desc'], indicesToCheck); return [...acc, ...sortedIndicesToCheck]; }, []); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx new file mode 100644 index 0000000000000..f2aa7a2666c33 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.test.tsx @@ -0,0 +1,377 @@ +/* + * 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 numeral from '@elastic/numeral'; +import userEvent from '@testing-library/user-event'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { mockMappingsResponse } from '../../../../mock/mappings_response/mock_mappings_response'; +import { TestProviders } from '../../../../mock/test_providers/test_providers'; +import { mockUnallowedValuesResponse } from '../../../../mock/unallowed_values/mock_unallowed_values'; +import { CANCEL, CHECK_ALL } from '../../../../translations'; +import { + OnCheckCompleted, + PartitionedFieldMetadata, + UnallowedValueRequestItem, +} from '../../../../types'; +import { CheckAll } from '.'; +import { EMPTY_STAT } from '../../../../helpers'; + +const defaultBytesFormat = '0,0.[0]b'; +const mockFormatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const mockFormatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + new Promise((resolve) => { + resolve(mockMappingsResponse); // happy path + }) +); + +jest.mock('../../../../use_mappings/helpers', () => ({ + fetchMappings: ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + mockFetchMappings({ + abortController, + patternOrIndexName, + }), +})); + +const mockFetchUnallowedValues = jest.fn( + ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => new Promise((resolve) => resolve(mockUnallowedValuesResponse)) +); + +jest.mock('../../../../use_unallowed_values/helpers', () => { + const original = jest.requireActual('../../../../use_unallowed_values/helpers'); + + return { + ...original, + fetchUnallowedValues: ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => + mockFetchUnallowedValues({ + abortController, + indexName, + requestItems, + }), + }; +}); + +const patternIndexNames = { + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], + 'auditbeat-*': [ + 'auditbeat-7.3.2-2023.03.27-000001', + '.ds-auditbeat-8.6.1-2023.03.29-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-7.10.2-2023.03.27-000001', + 'auditbeat-7.2.1-2023.03.27-000001', + 'auditbeat-custom-index-1', + ], + 'logs-*': [ + '.ds-logs-endpoint.events.process-default-2023.03.27-000001', + '.ds-logs-endpoint.alerts-default-2023.03.27-000001', + ], + 'packetbeat-*': [ + '.ds-packetbeat-8.6.1-2023.03.27-000001', + '.ds-packetbeat-8.5.3-2023.03.27-000001', + ], +}; + +const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; + +describe('CheckAll', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it renders the expected button text when a check is NOT running', () => { + render( + + + + ); + + expect(screen.getByTestId('checkAll')).toHaveTextContent(CHECK_ALL); + }); + + test('it renders the expected button text when a check is running', () => { + render( + + + + ); + + const button = screen.getByTestId('checkAll'); + + userEvent.click(button); // <-- START the check + + expect(screen.getByTestId('checkAll')).toHaveTextContent(CANCEL); + }); + + describe('formatNumber', () => { + test('it renders a comma-separated `value` via the `defaultNumberFormat`', async () => { + /** stores the result of invoking `CheckAll`'s `formatNumber` function */ + let formatNumberResult = ''; + + const onCheckCompleted: OnCheckCompleted = jest.fn( + ({ + formatBytes, + formatNumber, + }: { + error: string | null; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; + indexName: string; + partitionedFieldMetadata: PartitionedFieldMetadata | null; + pattern: string; + version: string; + }) => { + const value = 123456789; // numeric input to `CheckAll`'s `formatNumber` function + + formatNumberResult = formatNumber(value); + } + ); + + render( + + + + ); + + const button = screen.getByTestId('checkAll'); + + userEvent.click(button); // <-- START the check + + await waitFor(() => { + expect(formatNumberResult).toEqual('123,456,789'); // a comma-separated `value`, because it's numeric + }); + }); + + test('it renders an empty stat placeholder when `value` is undefined', async () => { + /** stores the result of invoking `CheckAll`'s `formatNumber` function */ + let formatNumberResult = ''; + + const onCheckCompleted: OnCheckCompleted = jest.fn( + ({ + formatBytes, + formatNumber, + }: { + error: string | null; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; + indexName: string; + partitionedFieldMetadata: PartitionedFieldMetadata | null; + pattern: string; + version: string; + }) => { + const value = undefined; // undefined input to `CheckAll`'s `formatNumber` function + + formatNumberResult = formatNumber(value); + } + ); + + render( + + + + ); + + const button = screen.getByTestId('checkAll'); + + userEvent.click(button); // <-- START the check + + await waitFor(() => { + expect(formatNumberResult).toEqual(EMPTY_STAT); // a placeholder, because `value` is undefined + }); + }); + }); + + describe('when a running check is cancelled', () => { + const setCheckAllIndiciesChecked = jest.fn(); + const setCheckAllTotalIndiciesToCheck = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + + const button = screen.getByTestId('checkAll'); + + userEvent.click(button); // <-- START the check + + userEvent.click(button); // <-- STOP the check + }); + + test('it invokes `setCheckAllIndiciesChecked` twice: when the check was started, and when it was cancelled', () => { + expect(setCheckAllIndiciesChecked).toHaveBeenCalledTimes(2); + }); + + test('it invokes `setCheckAllTotalIndiciesToCheck` with the expected index count when the check is STARTED', () => { + expect(setCheckAllTotalIndiciesToCheck.mock.calls[0][0]).toEqual(11); + }); + + test('it invokes `setCheckAllTotalIndiciesToCheck` with the expected index count when the check is STOPPED', () => { + expect(setCheckAllTotalIndiciesToCheck.mock.calls[1][0]).toEqual(0); + }); + }); + + describe('when all checks have completed', () => { + const setIndexToCheck = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.useFakeTimers(); + + render( + + + + ); + + const button = screen.getByTestId('checkAll'); + + userEvent.click(button); // <-- start the check + + const totalIndexNames = Object.values(patternIndexNames).reduce( + (total, indices) => total + indices.length, + 0 + ); + + // simulate the wall clock advancing + for (let i = 0; i < totalIndexNames + 1; i++) { + act(() => { + jest.advanceTimersByTime(1000 * 10); + }); + + await waitFor(() => {}); + } + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('it invokes setIndexToCheck with `null` after all the checks have completed', () => { + expect(setIndexToCheck).toBeCalledWith(null); + }); + + // test all the patterns + Object.entries(patternIndexNames).forEach((pattern) => { + const [patternName, indexNames] = pattern; + + // test each index in the pattern + indexNames.forEach((indexName) => { + test(`it invokes setIndexToCheck with the expected value for the '${patternName}' pattern's index, named '${indexName}'`, () => { + expect(setIndexToCheck).toBeCalledWith({ + indexName, + pattern: patternName, + }); + }); + }); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx index ab5de8d7ccdcb..ef768249aa2a4 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/check_all/index.tsx @@ -8,15 +8,18 @@ import { EcsFlat, EcsVersion } from '@kbn/ecs'; import { EuiButton } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; import { checkIndex } from './check_index'; -import { EMPTY_STAT } from '../../../../helpers'; import { getAllIndicesToCheck } from './helpers'; import * as i18n from '../../../../translations'; import type { EcsMetadata, IndexToCheck, OnCheckCompleted } from '../../../../types'; +const CheckAllButton = styled(EuiButton)` + width: 112px; +`; + async function wait(ms: number) { const delay = () => new Promise((resolve) => @@ -29,7 +32,8 @@ async function wait(ms: number) { } interface Props { - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmPhases: string[]; incrementCheckAllIndiciesChecked: () => void; onCheckCompleted: OnCheckCompleted; @@ -43,7 +47,8 @@ interface Props { const DELAY_AFTER_EVERY_CHECK_COMPLETES = 3000; // ms const CheckAllComponent: React.FC = ({ - defaultNumberFormat, + formatBytes, + formatNumber, ilmPhases, incrementCheckAllIndiciesChecked, onCheckCompleted, @@ -55,11 +60,6 @@ const CheckAllComponent: React.FC = ({ }) => { const abortController = useRef(new AbortController()); const [isRunning, setIsRunning] = useState(false); - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); const cancelIfRunning = useCallback(() => { if (isRunning) { @@ -89,6 +89,7 @@ const CheckAllComponent: React.FC = ({ await checkIndex({ abortController: abortController.current, ecsMetadata: EcsFlat as unknown as Record, + formatBytes, formatNumber, indexName, onCheckCompleted, @@ -118,6 +119,7 @@ const CheckAllComponent: React.FC = ({ } }, [ cancelIfRunning, + formatBytes, formatNumber, incrementCheckAllIndiciesChecked, isRunning, @@ -141,14 +143,18 @@ const CheckAllComponent: React.FC = ({ }; }, [abortController]); + const disabled = ilmPhases.length === 0; + return ( - {isRunning ? i18n.CANCEL : i18n.CHECK_ALL} - + ); }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.test.tsx new file mode 100644 index 0000000000000..7d139121afbc4 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EMPTY_STAT } from '../../../helpers'; +import { alertIndexWithAllResults } from '../../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { TestProviders } from '../../../mock/test_providers/test_providers'; +import { PatternRollup } from '../../../types'; +import { Props, SummaryActions } from '.'; +import { + getTotalDocsCount, + getTotalIncompatible, + getTotalIndices, + getTotalIndicesChecked, + getTotalSizeInBytes, +} from '../../../use_results_rollup/helpers'; + +const mockCopyToClipboard = jest.fn((value) => true); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + copyToClipboard: (value: string) => mockCopyToClipboard(value), + }; +}); + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const patternIndexNames: Record = { + 'auditbeat-*': [ + '.ds-auditbeat-8.6.1-2023.02.07-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-custom-index-1', + ], + '.alerts-security.alerts-default': ['.internal.alerts-security.alerts-default-000001'], + 'packetbeat-*': [ + '.ds-packetbeat-8.5.3-2023.02.04-000001', + '.ds-packetbeat-8.6.1-2023.02.04-000001', + ], +}; + +const lastChecked = '2023-03-28T23:27:28.159Z'; + +const totalDocsCount = getTotalDocsCount(patternRollups); +const totalIncompatible = getTotalIncompatible(patternRollups); +const totalIndices = getTotalIndices(patternRollups); +const totalIndicesChecked = getTotalIndicesChecked(patternRollups); +const totalSizeInBytes = getTotalSizeInBytes(patternRollups); + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: () => true, + errorSummary: [], + formatBytes, + formatNumber, + ilmPhases, + lastChecked, + openCreateCaseFlyout: jest.fn(), + onCheckCompleted: jest.fn(), + patternIndexNames, + patternRollups, + patterns, + setLastChecked: jest.fn(), + totalDocsCount, + totalIncompatible, + totalIndices, + totalIndicesChecked, + sizeInBytes: totalSizeInBytes, +}; + +describe('SummaryActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the check all button', () => { + expect(screen.getByTestId('checkAll')).toBeInTheDocument(); + }); + + test('it renders the check status indicator', () => { + expect(screen.getByTestId('checkStatus')).toBeInTheDocument(); + }); + + test('it renders the actions', () => { + expect(screen.getByTestId('actions')).toBeInTheDocument(); + }); + + test('it invokes addSuccessToast when the copy to clipboard button is clicked', () => { + const button = screen.getByTestId('copyToClipboard'); + + userEvent.click(button); + + expect(defaultProps.addSuccessToast).toBeCalledWith({ + title: 'Copied results to the clipboard', + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.tsx index d659d81d93199..db53376746281 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/index.tsx @@ -6,15 +6,14 @@ */ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import { sortBy } from 'lodash/fp'; -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import styled from 'styled-components'; import { CheckAll } from './check_all'; +import { CheckStatus } from '../check_status'; import { ERROR, INDEX, PATTERN } from '../errors_viewer/translations'; import { ERRORS } from '../errors_popover/translations'; -import { EMPTY_STAT } from '../../../helpers'; import { getDataQualitySummaryMarkdownComment, getErrorsMarkdownTable, @@ -23,8 +22,8 @@ import { getSummaryTableMarkdownHeader, getSummaryTableMarkdownRow, } from '../../index_properties/markdown/helpers'; -import { getSummaryTableItems } from '../../pattern/helpers'; -import { TakeActionMenu } from './take_action_menu'; +import { defaultSort, getSummaryTableItems } from '../../pattern/helpers'; +import { Actions } from './actions'; import type { DataQualityCheckResult, ErrorSummary, @@ -32,6 +31,7 @@ import type { OnCheckCompleted, PatternRollup, } from '../../../types'; +import { getSizeInBytes } from '../../../helpers'; const SummaryActionsFlexGroup = styled(EuiFlexGroup)` gap: ${({ theme }) => theme.eui.euiSizeS}; @@ -43,10 +43,12 @@ export const getResultsSortedByDocsCount = ( results != null ? sortBy('docsCount', Object.values(results)).reverse() : []; export const getAllMarkdownCommentsFromResults = ({ + formatBytes, formatNumber, patternIndexNames, patternRollup, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; patternIndexNames: Record; patternRollup: PatternRollup; @@ -60,6 +62,8 @@ export const getAllMarkdownCommentsFromResults = ({ pattern: patternRollup.pattern, patternDocsCount: patternRollup.docsCount ?? 0, results: patternRollup.results, + sortByColumn: defaultSort.sort.field, + sortByDirection: defaultSort.sort.direction, stats: patternRollup.stats, }); @@ -69,11 +73,13 @@ export const getAllMarkdownCommentsFromResults = ({ return getSummaryTableMarkdownRow({ docsCount: item.docsCount, + formatBytes, formatNumber, ilmPhase: item.ilmPhase, indexName: item.indexName, incompatible: result?.incompatible, patternDocsCount: patternRollup.docsCount ?? 0, + sizeInBytes: getSizeInBytes({ indexName: item.indexName, stats: patternRollup.stats }), }).trim(); }); @@ -89,10 +95,12 @@ export const getAllMarkdownCommentsFromResults = ({ }; export const getAllMarkdownComments = ({ + formatBytes, formatNumber, patternIndexNames, patternRollups, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; patternIndexNames: Record; patternRollups: Record; @@ -108,10 +116,12 @@ export const getAllMarkdownComments = ({ (acc, pattern) => [ ...acc, getPatternSummaryMarkdownComment({ + formatBytes, formatNumber, patternRollup: patternRollups[pattern], }), ...getAllMarkdownCommentsFromResults({ + formatBytes, formatNumber, patternRollup: patternRollups[pattern], patternIndexNames, @@ -121,13 +131,14 @@ export const getAllMarkdownComments = ({ ); }; -interface Props { +export interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; errorSummary: ErrorSummary[]; ilmPhases: string[]; - incrementCheckAllIndiciesChecked: () => void; + lastChecked: string; onCheckCompleted: OnCheckCompleted; openCreateCaseFlyout: ({ comments, @@ -139,51 +150,54 @@ interface Props { patternIndexNames: Record; patternRollups: Record; patterns: string[]; - setCheckAllIndiciesChecked: (checkAllIndiciesChecked: number) => void; - setCheckAllTotalIndiciesToCheck: (checkAllTotalIndiciesToCheck: number) => void; - setIndexToCheck: (indexToCheck: IndexToCheck | null) => void; + setLastChecked: (lastChecked: string) => void; totalDocsCount: number | undefined; totalIncompatible: number | undefined; totalIndices: number | undefined; totalIndicesChecked: number | undefined; + sizeInBytes: number | undefined; } const SummaryActionsComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, errorSummary, ilmPhases, - incrementCheckAllIndiciesChecked, + lastChecked, onCheckCompleted, openCreateCaseFlyout, patternIndexNames, patternRollups, patterns, + setLastChecked, totalDocsCount, - setCheckAllIndiciesChecked, - setCheckAllTotalIndiciesToCheck, - setIndexToCheck, totalIncompatible, totalIndices, totalIndicesChecked, + sizeInBytes, }) => { - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); + const [indexToCheck, setIndexToCheck] = useState(null); + const [checkAllIndiciesChecked, setCheckAllIndiciesChecked] = useState(0); + const [checkAllTotalIndiciesToCheck, setCheckAllTotalIndiciesToCheck] = useState(0); + const incrementCheckAllIndiciesChecked = useCallback(() => { + setCheckAllIndiciesChecked((current) => current + 1); + }, []); const getMarkdownComments = useCallback( (): string[] => [ getDataQualitySummaryMarkdownComment({ + formatBytes, formatNumber, totalDocsCount, totalIncompatible, totalIndices, totalIndicesChecked, + sizeInBytes, }), ...getAllMarkdownComments({ + formatBytes, formatNumber, patternIndexNames, patternRollups, @@ -197,9 +211,11 @@ const SummaryActionsComponent: React.FC = ({ ], [ errorSummary, + formatBytes, formatNumber, patternIndexNames, patternRollups, + sizeInBytes, totalDocsCount, totalIncompatible, totalIndices, @@ -208,30 +224,46 @@ const SummaryActionsComponent: React.FC = ({ ); return ( - - - - - - - - - + <> + + + + + + + + + + + + + + ); }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/index.tsx deleted file mode 100644 index 4683a96a76987..0000000000000 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/data_quality_summary/summary_actions/take_action_menu/index.tsx +++ /dev/null @@ -1,125 +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 { - copyToClipboard, - EuiButtonEmpty, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiPopover, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; - -import { - ADD_TO_NEW_CASE, - COPIED_RESULTS_TOAST_TITLE, - COPY_TO_CLIPBOARD, -} from '../../../../translations'; -import * as i18n from './translations'; -import { useAddToNewCase } from '../../../../use_add_to_new_case'; - -interface Props { - addSuccessToast: (toast: { title: string }) => void; - canUserCreateAndReadCases: () => boolean; - getMarkdownComments: () => string[]; - openCreateCaseFlyout: ({ - comments, - headerContent, - }: { - comments: string[]; - headerContent?: React.ReactNode; - }) => void; -} - -const TakeActionMenuComponent: React.FC = ({ - addSuccessToast, - canUserCreateAndReadCases, - getMarkdownComments, - openCreateCaseFlyout, -}) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const closePopover = useCallback(() => { - setIsPopoverOpen(false); - }, []); - const onButtonClick = useCallback(() => { - setIsPopoverOpen((current) => !current); - }, []); - - const takeActionButton = useMemo( - () => ( - - {i18n.TAKE_ACTION} - - ), - [onButtonClick] - ); - - const { disabled: addToNewCaseDisabled, onAddToNewCase } = useAddToNewCase({ - canUserCreateAndReadCases, - openCreateCaseFlyout, - }); - - const onClickAddToCase = useCallback( - () => onAddToNewCase([getMarkdownComments().join('\n')]), - [getMarkdownComments, onAddToNewCase] - ); - - const onCopy = useCallback(() => { - const markdown = getMarkdownComments().join('\n'); - copyToClipboard(markdown); - - closePopover(); - - addSuccessToast({ - title: COPIED_RESULTS_TOAST_TITLE, - }); - }, [addSuccessToast, closePopover, getMarkdownComments]); - - const addToNewCaseContextMenuOnClick = useCallback(() => { - closePopover(); - onClickAddToCase(); - }, [closePopover, onClickAddToCase]); - - const items = useMemo( - () => [ - - {ADD_TO_NEW_CASE} - , - - - {COPY_TO_CLIPBOARD} - , - ], - [addToNewCaseContextMenuOnClick, addToNewCaseDisabled, onCopy] - ); - - return ( - - - - ); -}; - -TakeActionMenuComponent.displayName = 'TakeActionMenuComponent'; - -export const TakeActionMenu = React.memo(TakeActionMenuComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.test.tsx new file mode 100644 index 0000000000000..f57d9f52737d7 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { ErrorEmptyPrompt } from '.'; + +describe('ErrorEmptyPrompt', () => { + test('it renders the expected content', () => { + const title = 'This is the title of this work'; + + render( + + + + ); + + expect(screen.getByTestId('errorEmptyPrompt').textContent?.includes(title)).toBe(true); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.tsx index a0b70daee0e40..3214b704dc685 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/error_empty_prompt/index.tsx @@ -15,7 +15,7 @@ interface Props { } const ErrorEmptyPromptComponent: React.FC = ({ title }) => ( - +

{i18n.ERRORS_MAY_OCCUR}

{i18n.THE_FOLLOWING_PRIVILEGES_ARE_REQUIRED} diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.test.tsx new file mode 100644 index 0000000000000..6efe7579d7325 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.test.tsx @@ -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 { + IlmExplainLifecycleLifecycleExplain, + IlmExplainLifecycleLifecycleExplainManaged, + IlmExplainLifecycleLifecycleExplainUnmanaged, +} from '@elastic/elasticsearch/lib/api/types'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { IlmPhaseCounts } from '.'; +import { getIlmExplainPhaseCounts } from '../pattern/helpers'; + +const hot: IlmExplainLifecycleLifecycleExplainManaged = { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '3.98d', + lifecycle_date_millis: 1675536751379, + age: '3.98d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, +}; +const warm = { + ...hot, + phase: 'warm', +}; +const cold = { + ...hot, + phase: 'cold', +}; +const frozen = { + ...hot, + phase: 'frozen', +}; + +const managed: Record = { + hot, + warm, + cold, + frozen, +}; + +const unmanaged: IlmExplainLifecycleLifecycleExplainUnmanaged = { + index: 'foo', + managed: false, +}; + +const ilmExplain: Record = { + ...managed, + [unmanaged.index]: unmanaged, +}; + +const ilmExplainPhaseCounts = getIlmExplainPhaseCounts(ilmExplain); + +const pattern = 'packetbeat-*'; + +describe('IlmPhaseCounts', () => { + test('it renders the expected counts', () => { + render( + + + + ); + + expect(screen.getByTestId('ilmPhaseCounts')).toHaveTextContent( + 'hot (1)unmanaged (1)warm (1)cold (1)frozen (1)' + ); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.tsx index 3aa738d4cb788..82664778becb0 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/ilm_phase_counts/index.tsx @@ -25,7 +25,7 @@ interface Props { } const IlmPhaseCountsComponent: React.FC = ({ ilmExplainPhaseCounts, pattern }) => ( - + {phases.map((phase) => ilmExplainPhaseCounts[phase] != null && ilmExplainPhaseCounts[phase] > 0 ? ( diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.test.tsx new file mode 100644 index 0000000000000..1c37ec799c53c --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EmptyPromptBody } from './empty_prompt_body'; +import { TestProviders } from '../../mock/test_providers/test_providers'; + +describe('EmptyPromptBody', () => { + const content = 'foo bar baz @baz'; + + test('it renders the expected content', () => { + render( + + + + ); + + expect(screen.getByTestId('emptyPromptBody')).toHaveTextContent(content); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.tsx index 80c9151eaa050..33283d11649a8 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_body.tsx @@ -11,7 +11,9 @@ interface Props { body: string; } -const EmptyPromptBodyComponent: React.FC = ({ body }) =>

{body}

; +const EmptyPromptBodyComponent: React.FC = ({ body }) => ( +

{body}

+); EmptyPromptBodyComponent.displayName = 'EmptyPromptBodyComponent'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.test.tsx new file mode 100644 index 0000000000000..6bb3b72ed3ece --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EmptyPromptTitle } from './empty_prompt_title'; +import { TestProviders } from '../../mock/test_providers/test_providers'; + +describe('EmptyPromptTitle', () => { + const title = 'What is a great title?'; + + test('it renders the expected content', () => { + render( + + + + ); + + expect(screen.getByTestId('emptyPromptTitle')).toHaveTextContent(title); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.tsx index a9d2e1f6a74d9..ee06b2f446858 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/empty_prompt_title.tsx @@ -11,7 +11,9 @@ interface Props { title: string; } -const EmptyPromptTitleComponent: React.FC = ({ title }) =>

{title}

; +const EmptyPromptTitleComponent: React.FC = ({ title }) => ( +

{title}

+); EmptyPromptTitleComponent.displayName = 'EmptyPromptTitleComponent'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/helpers.test.ts new file mode 100644 index 0000000000000..e0644fdc4a5c0 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/helpers.test.ts @@ -0,0 +1,800 @@ +/* + * 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 { EcsFlat } from '@kbn/ecs'; + +import { + getMappingsProperties, + getSortedPartitionedFieldMetadata, + hasAllDataFetchingCompleted, +} from './helpers'; +import { mockIndicesGetMappingIndexMappingRecords } from '../../mock/indices_get_mapping_index_mapping_record/mock_indices_get_mapping_index_mapping_record'; +import { mockMappingsProperties } from '../../mock/mappings_properties/mock_mappings_properties'; +import { EcsMetadata } from '../../types'; + +const ecsMetadata: Record = EcsFlat as unknown as Record; + +describe('helpers', () => { + describe('getSortedPartitionedFieldMetadata', () => { + test('it returns null when mappings are loading', () => { + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata, + loadingMappings: true, // <-- + mappingsProperties: mockMappingsProperties, + unallowedValues: {}, + }) + ).toBeNull(); + }); + + test('it returns null when `ecsMetadata` is null', () => { + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata: null, // <-- + loadingMappings: false, + mappingsProperties: mockMappingsProperties, + unallowedValues: {}, + }) + ).toBeNull(); + }); + + test('it returns null when `unallowedValues` is null', () => { + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata, + loadingMappings: false, + mappingsProperties: mockMappingsProperties, + unallowedValues: null, // <-- + }) + ).toBeNull(); + }); + + describe('when `mappingsProperties` is unknown', () => { + const expected = { + all: [], + custom: [], + ecsCompliant: [], + incompatible: [ + { + description: + 'Date/time when the event originated. This is the date/time extracted from the event, typically representing when the event was generated by the source. If the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline. Required field for all events.', + hasEcsMetadata: true, + indexFieldName: '@timestamp', + indexFieldType: '-', + indexInvalidValues: [], + isEcsCompliant: false, + isInSameFamily: false, + type: 'date', + }, + ], + }; + + test('it returns a `PartitionedFieldMetadata` with an `incompatible` `@timestamp` when `mappingsProperties` is undefined', () => { + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata, + loadingMappings: false, + mappingsProperties: undefined, // <-- + unallowedValues: {}, + }) + ).toEqual(expected); + }); + + test('it returns a `PartitionedFieldMetadata` with an `incompatible` `@timestamp` when `mappingsProperties` is null', () => { + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata, + loadingMappings: false, + mappingsProperties: null, // <-- + unallowedValues: {}, + }) + ).toEqual(expected); + }); + }); + + test('it returns the expected sorted field metadata', () => { + const unallowedValues = { + 'event.category': [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ], + 'event.kind': [], + 'event.outcome': [], + 'event.type': [], + }; + + expect( + getSortedPartitionedFieldMetadata({ + ecsMetadata, + loadingMappings: false, + mappingsProperties: mockMappingsProperties, + unallowedValues, + }) + ).toEqual({ + all: [ + { + dashed_name: 'timestamp', + description: + 'Date/time when the event originated.\nThis is the date/time extracted from the event, typically representing when the event was generated by the source.\nIf the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline.\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + flat_name: '@timestamp', + level: 'core', + name: '@timestamp', + normalize: [], + required: true, + short: 'Date/time when the event originated.', + type: 'date', + indexFieldName: '@timestamp', + indexFieldType: 'date', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: true, + isInSameFamily: false, + }, + { + allowed_values: [ + { + description: + 'Events in this category are related to the challenge and response process in which credentials are supplied and verified to allow the creation of a session. Common sources for these logs are Windows event logs and ssh logs. Visualize and analyze events in this category to look for failed logins, and other authentication-related activity.', + expected_event_types: ['start', 'end', 'info'], + name: 'authentication', + }, + { + description: + 'Events in the configuration category have to deal with creating, modifying, or deleting the settings or parameters of an application, process, or system.\nExample sources include security policy change logs, configuration auditing logging, and system integrity monitoring.', + expected_event_types: ['access', 'change', 'creation', 'deletion', 'info'], + name: 'configuration', + }, + { + description: + 'The database category denotes events and metrics relating to a data storage and retrieval system. Note that use of this category is not limited to relational database systems. Examples include event logs from MS SQL, MySQL, Elasticsearch, MongoDB, etc. Use this category to visualize and analyze database activity such as accesses and changes.', + expected_event_types: ['access', 'change', 'info', 'error'], + name: 'database', + }, + { + description: + 'Events in the driver category have to do with operating system device drivers and similar software entities such as Windows drivers, kernel extensions, kernel modules, etc.\nUse events and metrics in this category to visualize and analyze driver-related activity and status on hosts.', + expected_event_types: ['change', 'end', 'info', 'start'], + name: 'driver', + }, + { + description: + 'This category is used for events relating to email messages, email attachments, and email network or protocol activity.\nEmails events can be produced by email security gateways, mail transfer agents, email cloud service providers, or mail server monitoring applications.', + expected_event_types: ['info'], + name: 'email', + }, + { + description: + 'Relating to a set of information that has been created on, or has existed on a filesystem. Use this category of events to visualize and analyze the creation, access, and deletions of files. Events in this category can come from both host-based and network-based sources. An example source of a network-based detection of a file transfer would be the Zeek file.log.', + expected_event_types: ['change', 'creation', 'deletion', 'info'], + name: 'file', + }, + { + description: + 'Use this category to visualize and analyze information such as host inventory or host lifecycle events.\nMost of the events in this category can usually be observed from the outside, such as from a hypervisor or a control plane\'s point of view. Some can also be seen from within, such as "start" or "end".\nNote that this category is for information about hosts themselves; it is not meant to capture activity "happening on a host".', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'host', + }, + { + description: + 'Identity and access management (IAM) events relating to users, groups, and administration. Use this category to visualize and analyze IAM-related logs and data from active directory, LDAP, Okta, Duo, and other IAM systems.', + expected_event_types: [ + 'admin', + 'change', + 'creation', + 'deletion', + 'group', + 'info', + 'user', + ], + name: 'iam', + }, + { + description: + 'Relating to intrusion detections from IDS/IPS systems and functions, both network and host-based. Use this category to visualize and analyze intrusion detection alerts from systems such as Snort, Suricata, and Palo Alto threat detections.', + expected_event_types: ['allowed', 'denied', 'info'], + name: 'intrusion_detection', + }, + { + description: + 'Malware detection events and alerts. Use this category to visualize and analyze malware detections from EDR/EPP systems such as Elastic Endpoint Security, Symantec Endpoint Protection, Crowdstrike, and network IDS/IPS systems such as Suricata, or other sources of malware-related events such as Palo Alto Networks threat logs and Wildfire logs.', + expected_event_types: ['info'], + name: 'malware', + }, + { + description: + 'Relating to all network activity, including network connection lifecycle, network traffic, and essentially any event that includes an IP address. Many events containing decoded network protocol transactions fit into this category. Use events in this category to visualize or analyze counts of network ports, protocols, addresses, geolocation information, etc.', + expected_event_types: [ + 'access', + 'allowed', + 'connection', + 'denied', + 'end', + 'info', + 'protocol', + 'start', + ], + name: 'network', + }, + { + description: + 'Relating to software packages installed on hosts. Use this category to visualize and analyze inventory of software installed on various hosts, or to determine host vulnerability in the absence of vulnerability scan data.', + expected_event_types: [ + 'access', + 'change', + 'deletion', + 'info', + 'installation', + 'start', + ], + name: 'package', + }, + { + description: + 'Use this category of events to visualize and analyze process-specific information such as lifecycle events or process ancestry.', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'process', + }, + { + description: + 'Having to do with settings and assets stored in the Windows registry. Use this category to visualize and analyze activity such as registry access and modifications.', + expected_event_types: ['access', 'change', 'creation', 'deletion'], + name: 'registry', + }, + { + description: + 'The session category is applied to events and metrics regarding logical persistent connections to hosts and services. Use this category to visualize and analyze interactive or automated persistent connections between assets. Data for this category may come from Windows Event logs, SSH logs, or stateless sessions such as HTTP cookie-based sessions, etc.', + expected_event_types: ['start', 'end', 'info'], + name: 'session', + }, + { + description: + "Use this category to visualize and analyze events describing threat actors' targets, motives, or behaviors.", + expected_event_types: ['indicator'], + name: 'threat', + }, + { + description: + 'Relating to vulnerability scan results. Use this category to analyze vulnerabilities detected by Tenable, Qualys, internal scanners, and other vulnerability management sources.', + expected_event_types: ['info'], + name: 'vulnerability', + }, + { + description: + 'Relating to web server access. Use this category to create a dashboard of web server/proxy activity from apache, IIS, nginx web servers, etc. Note: events from network observers such as Zeek http log may also be included in this category.', + expected_event_types: ['access', 'error', 'info'], + name: 'web', + }, + ], + dashed_name: 'event-category', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy.\n`event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory.\nThis field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + flat_name: 'event.category', + ignore_above: 1024, + level: 'core', + name: 'category', + normalize: ['array'], + short: 'Event category. The second categorization field in the hierarchy.', + type: 'keyword', + indexFieldName: 'event.category', + indexFieldType: 'keyword', + indexInvalidValues: [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: true, + }, + { + dashed_name: 'host-name', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + flat_name: 'host.name', + ignore_above: 1024, + level: 'core', + name: 'name', + normalize: [], + short: 'Name of the host.', + type: 'keyword', + indexFieldName: 'host.name', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'host.name.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + dashed_name: 'source-ip', + description: 'IP address of the source (IPv4 or IPv6).', + flat_name: 'source.ip', + level: 'core', + name: 'ip', + normalize: [], + short: 'IP address of the source.', + type: 'ip', + indexFieldName: 'source.ip', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'source.ip.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + dashed_name: 'source-port', + description: 'Port of the source.', + flat_name: 'source.port', + format: 'string', + level: 'core', + name: 'port', + normalize: [], + short: 'Port of the source.', + type: 'long', + indexFieldName: 'source.port', + indexFieldType: 'long', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: true, + isInSameFamily: false, + }, + ], + ecsCompliant: [ + { + dashed_name: 'timestamp', + description: + 'Date/time when the event originated.\nThis is the date/time extracted from the event, typically representing when the event was generated by the source.\nIf the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline.\nRequired field for all events.', + example: '2016-05-23T08:05:34.853Z', + flat_name: '@timestamp', + level: 'core', + name: '@timestamp', + normalize: [], + required: true, + short: 'Date/time when the event originated.', + type: 'date', + indexFieldName: '@timestamp', + indexFieldType: 'date', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: true, + isInSameFamily: false, + }, + { + dashed_name: 'source-port', + description: 'Port of the source.', + flat_name: 'source.port', + format: 'string', + level: 'core', + name: 'port', + normalize: [], + short: 'Port of the source.', + type: 'long', + indexFieldName: 'source.port', + indexFieldType: 'long', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: true, + isInSameFamily: false, + }, + ], + custom: [ + { + indexFieldName: 'host.name.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'source.ip.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + ], + incompatible: [ + { + allowed_values: [ + { + description: + 'Events in this category are related to the challenge and response process in which credentials are supplied and verified to allow the creation of a session. Common sources for these logs are Windows event logs and ssh logs. Visualize and analyze events in this category to look for failed logins, and other authentication-related activity.', + expected_event_types: ['start', 'end', 'info'], + name: 'authentication', + }, + { + description: + 'Events in the configuration category have to deal with creating, modifying, or deleting the settings or parameters of an application, process, or system.\nExample sources include security policy change logs, configuration auditing logging, and system integrity monitoring.', + expected_event_types: ['access', 'change', 'creation', 'deletion', 'info'], + name: 'configuration', + }, + { + description: + 'The database category denotes events and metrics relating to a data storage and retrieval system. Note that use of this category is not limited to relational database systems. Examples include event logs from MS SQL, MySQL, Elasticsearch, MongoDB, etc. Use this category to visualize and analyze database activity such as accesses and changes.', + expected_event_types: ['access', 'change', 'info', 'error'], + name: 'database', + }, + { + description: + 'Events in the driver category have to do with operating system device drivers and similar software entities such as Windows drivers, kernel extensions, kernel modules, etc.\nUse events and metrics in this category to visualize and analyze driver-related activity and status on hosts.', + expected_event_types: ['change', 'end', 'info', 'start'], + name: 'driver', + }, + { + description: + 'This category is used for events relating to email messages, email attachments, and email network or protocol activity.\nEmails events can be produced by email security gateways, mail transfer agents, email cloud service providers, or mail server monitoring applications.', + expected_event_types: ['info'], + name: 'email', + }, + { + description: + 'Relating to a set of information that has been created on, or has existed on a filesystem. Use this category of events to visualize and analyze the creation, access, and deletions of files. Events in this category can come from both host-based and network-based sources. An example source of a network-based detection of a file transfer would be the Zeek file.log.', + expected_event_types: ['change', 'creation', 'deletion', 'info'], + name: 'file', + }, + { + description: + 'Use this category to visualize and analyze information such as host inventory or host lifecycle events.\nMost of the events in this category can usually be observed from the outside, such as from a hypervisor or a control plane\'s point of view. Some can also be seen from within, such as "start" or "end".\nNote that this category is for information about hosts themselves; it is not meant to capture activity "happening on a host".', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'host', + }, + { + description: + 'Identity and access management (IAM) events relating to users, groups, and administration. Use this category to visualize and analyze IAM-related logs and data from active directory, LDAP, Okta, Duo, and other IAM systems.', + expected_event_types: [ + 'admin', + 'change', + 'creation', + 'deletion', + 'group', + 'info', + 'user', + ], + name: 'iam', + }, + { + description: + 'Relating to intrusion detections from IDS/IPS systems and functions, both network and host-based. Use this category to visualize and analyze intrusion detection alerts from systems such as Snort, Suricata, and Palo Alto threat detections.', + expected_event_types: ['allowed', 'denied', 'info'], + name: 'intrusion_detection', + }, + { + description: + 'Malware detection events and alerts. Use this category to visualize and analyze malware detections from EDR/EPP systems such as Elastic Endpoint Security, Symantec Endpoint Protection, Crowdstrike, and network IDS/IPS systems such as Suricata, or other sources of malware-related events such as Palo Alto Networks threat logs and Wildfire logs.', + expected_event_types: ['info'], + name: 'malware', + }, + { + description: + 'Relating to all network activity, including network connection lifecycle, network traffic, and essentially any event that includes an IP address. Many events containing decoded network protocol transactions fit into this category. Use events in this category to visualize or analyze counts of network ports, protocols, addresses, geolocation information, etc.', + expected_event_types: [ + 'access', + 'allowed', + 'connection', + 'denied', + 'end', + 'info', + 'protocol', + 'start', + ], + name: 'network', + }, + { + description: + 'Relating to software packages installed on hosts. Use this category to visualize and analyze inventory of software installed on various hosts, or to determine host vulnerability in the absence of vulnerability scan data.', + expected_event_types: [ + 'access', + 'change', + 'deletion', + 'info', + 'installation', + 'start', + ], + name: 'package', + }, + { + description: + 'Use this category of events to visualize and analyze process-specific information such as lifecycle events or process ancestry.', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'process', + }, + { + description: + 'Having to do with settings and assets stored in the Windows registry. Use this category to visualize and analyze activity such as registry access and modifications.', + expected_event_types: ['access', 'change', 'creation', 'deletion'], + name: 'registry', + }, + { + description: + 'The session category is applied to events and metrics regarding logical persistent connections to hosts and services. Use this category to visualize and analyze interactive or automated persistent connections between assets. Data for this category may come from Windows Event logs, SSH logs, or stateless sessions such as HTTP cookie-based sessions, etc.', + expected_event_types: ['start', 'end', 'info'], + name: 'session', + }, + { + description: + "Use this category to visualize and analyze events describing threat actors' targets, motives, or behaviors.", + expected_event_types: ['indicator'], + name: 'threat', + }, + { + description: + 'Relating to vulnerability scan results. Use this category to analyze vulnerabilities detected by Tenable, Qualys, internal scanners, and other vulnerability management sources.', + expected_event_types: ['info'], + name: 'vulnerability', + }, + { + description: + 'Relating to web server access. Use this category to create a dashboard of web server/proxy activity from apache, IIS, nginx web servers, etc. Note: events from network observers such as Zeek http log may also be included in this category.', + expected_event_types: ['access', 'error', 'info'], + name: 'web', + }, + ], + dashed_name: 'event-category', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy.\n`event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory.\nThis field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + flat_name: 'event.category', + ignore_above: 1024, + level: 'core', + name: 'category', + normalize: ['array'], + short: 'Event category. The second categorization field in the hierarchy.', + type: 'keyword', + indexFieldName: 'event.category', + indexFieldType: 'keyword', + indexInvalidValues: [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: true, + }, + { + dashed_name: 'host-name', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + flat_name: 'host.name', + ignore_above: 1024, + level: 'core', + name: 'name', + normalize: [], + short: 'Name of the host.', + type: 'keyword', + indexFieldName: 'host.name', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + dashed_name: 'source-ip', + description: 'IP address of the source (IPv4 or IPv6).', + flat_name: 'source.ip', + level: 'core', + name: 'ip', + normalize: [], + short: 'IP address of the source.', + type: 'ip', + indexFieldName: 'source.ip', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, + ], + }); + }); + }); + + describe('getMappingsProperties', () => { + test('it returns the expected mapping properties', () => { + expect( + getMappingsProperties({ + indexes: mockIndicesGetMappingIndexMappingRecords, + indexName: 'auditbeat-custom-index-1', + }) + ).toEqual({ + '@timestamp': { + type: 'date', + }, + event: { + properties: { + category: { + ignore_above: 1024, + type: 'keyword', + }, + }, + }, + host: { + properties: { + name: { + fields: { + keyword: { + ignore_above: 256, + type: 'keyword', + }, + }, + type: 'text', + }, + }, + }, + some: { + properties: { + field: { + fields: { + keyword: { + ignore_above: 256, + type: 'keyword', + }, + }, + type: 'text', + }, + }, + }, + source: { + properties: { + ip: { + fields: { + keyword: { + ignore_above: 256, + type: 'keyword', + }, + }, + type: 'text', + }, + port: { + type: 'long', + }, + }, + }, + }); + }); + + test('it returns null when `indexes` is null', () => { + expect( + getMappingsProperties({ + indexes: null, // <-- + indexName: 'auditbeat-custom-index-1', + }) + ).toBeNull(); + }); + + test('it returns null when `indexName` does not exist in `indexes`', () => { + expect( + getMappingsProperties({ + indexes: mockIndicesGetMappingIndexMappingRecords, + indexName: 'does-not-exist', // <-- + }) + ).toBeNull(); + }); + + test('it returns null when `properties` does not exist in the mappings', () => { + const missingProperties = { + ...mockIndicesGetMappingIndexMappingRecords, + foozle: { + mappings: {}, // <-- does not have a `properties` + }, + }; + + expect( + getMappingsProperties({ + indexes: missingProperties, + indexName: 'foozle', + }) + ).toBeNull(); + }); + }); + + describe('hasAllDataFetchingCompleted', () => { + test('it returns false when both the mappings and unallowed values are loading', () => { + expect( + hasAllDataFetchingCompleted({ + loadingMappings: true, + loadingUnallowedValues: true, + }) + ).toBe(false); + }); + + test('it returns false when mappings are loading, and unallowed values are NOT loading', () => { + expect( + hasAllDataFetchingCompleted({ + loadingMappings: true, + loadingUnallowedValues: false, + }) + ).toBe(false); + }); + + test('it returns false when mappings are NOT loading, and unallowed values are loading', () => { + expect( + hasAllDataFetchingCompleted({ + loadingMappings: false, + loadingUnallowedValues: true, + }) + ).toBe(false); + }); + + test('it returns true when both the mappings and unallowed values have finished loading', () => { + expect( + hasAllDataFetchingCompleted({ + loadingMappings: false, + loadingUnallowedValues: false, + }) + ).toBe(true); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx new file mode 100644 index 0000000000000..b6914daef0e7d --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.test.tsx @@ -0,0 +1,262 @@ +/* + * 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 { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../helpers'; +import { mockMappingsResponse } from '../../mock/mappings_response/mock_mappings_response'; +import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { mockUnallowedValuesResponse } from '../../mock/unallowed_values/mock_unallowed_values'; +import { LOADING_MAPPINGS, LOADING_UNALLOWED_VALUES } from './translations'; +import { UnallowedValueRequestItem } from '../../types'; +import { IndexProperties, Props } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const pattern = 'auditbeat-*'; +const patternRollup = auditbeatWithAllResults; + +let mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + new Promise((resolve) => { + resolve(mockMappingsResponse); // happy path + }) +); + +jest.mock('../../use_mappings/helpers', () => ({ + fetchMappings: ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => + mockFetchMappings({ + abortController, + patternOrIndexName, + }), +})); + +let mockFetchUnallowedValues = jest.fn( + ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => new Promise((resolve) => resolve(mockUnallowedValuesResponse)) +); + +jest.mock('../../use_unallowed_values/helpers', () => { + const original = jest.requireActual('../../use_unallowed_values/helpers'); + + return { + ...original, + fetchUnallowedValues: ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => + mockFetchUnallowedValues({ + abortController, + indexName, + requestItems, + }), + }; +}); + +const defaultProps: Props = { + addSuccessToast: jest.fn(), + canUserCreateAndReadCases: jest.fn(), + docsCount: auditbeatWithAllResults.docsCount ?? 0, + formatBytes, + formatNumber, + getGroupByFieldsOnClick: jest.fn(), + ilmPhase: 'hot', + indexName: 'auditbeat-custom-index-1', + openCreateCaseFlyout: jest.fn(), + pattern, + patternRollup, + theme: DARK_THEME, + updatePatternRollup: jest.fn(), +}; + +describe('IndexProperties', () => { + test('it renders the tab content', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('incompatibleTab')).toBeInTheDocument(); + }); + }); + + describe('when an error occurs loading mappings', () => { + const abortController = new AbortController(); + abortController.abort(); + + const error = 'simulated fetch mappings error'; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('it displays the expected empty prompt content', async () => { + mockFetchMappings = jest.fn( + ({ + // eslint-disable-next-line @typescript-eslint/no-shadow + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => new Promise((_, reject) => reject(new Error(error))) + ); + + render( + + + + ); + + await waitFor(() => { + expect( + screen + .getByTestId('errorEmptyPrompt') + .textContent?.includes('Unable to load index mappings') + ).toBe(true); + }); + }); + }); + + describe('when an error occurs loading unallowed values', () => { + const abortController = new AbortController(); + abortController.abort(); + + const error = 'simulated fetch unallowed values error'; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('it displays the expected empty prompt content', async () => { + mockFetchUnallowedValues = jest.fn( + ({ + // eslint-disable-next-line @typescript-eslint/no-shadow + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => new Promise((_, reject) => reject(new Error(error))) + ); + + render( + + + + ); + + await waitFor(() => { + expect( + screen + .getByTestId('errorEmptyPrompt') + .textContent?.includes('Unable to load unallowed values') + ).toBe(true); + }); + }); + }); + + describe('when mappings are loading', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('it displays the expected loading prompt content', async () => { + mockFetchMappings = jest.fn( + ({ + abortController, + patternOrIndexName, + }: { + abortController: AbortController; + patternOrIndexName: string; + }) => new Promise(() => {}) // <-- will never resolve or reject + ); + + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByTestId('loadingEmptyPrompt').textContent?.includes(LOADING_MAPPINGS) + ).toBe(true); + }); + }); + }); + + describe('when unallowed values are loading', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('it displays the expected loading prompt content', async () => { + mockFetchUnallowedValues = jest.fn( + ({ + abortController, + indexName, + requestItems, + }: { + abortController: AbortController; + indexName: string; + requestItems: UnallowedValueRequestItem[]; + }) => new Promise(() => {}) // <-- will never resolve or reject + ); + + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByTestId('loadingEmptyPrompt').textContent?.includes(LOADING_UNALLOWED_VALUES) + ).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx index 34fac241a2d82..ca9eda507f8dd 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/index.tsx @@ -16,7 +16,6 @@ import type { XYChartElementEvent, } from '@elastic/charts'; import { EuiSpacer, EuiTab, EuiTabs } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { getUnallowedValueRequestItems } from '../allowed_values/helpers'; @@ -28,8 +27,8 @@ import { hasAllDataFetchingCompleted, INCOMPATIBLE_TAB_ID, } from './helpers'; -import { EMPTY_STAT } from '../../helpers'; import { LoadingEmptyPrompt } from '../loading_empty_prompt'; +import { getIndexPropertiesContainerId } from '../pattern/helpers'; import { getTabs } from '../tabs/helpers'; import { getAllIncompatibleMarkdownComments } from '../tabs/incompatible_tab/helpers'; import * as i18n from './translations'; @@ -40,10 +39,11 @@ import { useUnallowedValues } from '../../use_unallowed_values'; const EMPTY_MARKDOWN_COMMENTS: string[] = []; -interface Props { +export interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; docsCount: number; getGroupByFieldsOnClick: ( elements: Array< @@ -76,7 +76,8 @@ interface Props { const IndexPropertiesComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, docsCount, getGroupByFieldsOnClick, ilmPhase, @@ -87,11 +88,6 @@ const IndexPropertiesComponent: React.FC = ({ theme, updatePatternRollup, }) => { - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); const { error: mappingsError, indexes, loading: loadingMappings } = useMappings(indexName); const requestItems = useMemo( @@ -142,7 +138,8 @@ const IndexPropertiesComponent: React.FC = ({ getTabs({ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, + formatBytes, + formatNumber, docsCount, getGroupByFieldsOnClick, ilmPhase, @@ -152,13 +149,15 @@ const IndexPropertiesComponent: React.FC = ({ pattern, patternDocsCount: patternRollup?.docsCount ?? 0, setSelectedTabId, + stats: patternRollup?.stats ?? null, theme, }), [ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, getGroupByFieldsOnClick, ilmPhase, indexName, @@ -166,6 +165,7 @@ const IndexPropertiesComponent: React.FC = ({ partitionedFieldMetadata, pattern, patternRollup?.docsCount, + patternRollup?.stats, theme, ] ); @@ -212,11 +212,13 @@ const IndexPropertiesComponent: React.FC = ({ partitionedFieldMetadata != null ? getAllIncompatibleMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount: patternRollup.docsCount ?? 0, + sizeInBytes: patternRollup.sizeInBytes, }) : EMPTY_MARKDOWN_COMMENTS; @@ -239,6 +241,7 @@ const IndexPropertiesComponent: React.FC = ({ } }, [ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, @@ -265,10 +268,10 @@ const IndexPropertiesComponent: React.FC = ({ } return indexes != null ? ( - <> +
{renderTabs()} {selectedTabContent} - +
) : null; }; IndexPropertiesComponent.displayName = 'IndexPropertiesComponent'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.test.ts index 0ea7a13d1c710..b51a49ecc89df 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.test.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.test.ts @@ -5,11 +5,244 @@ * 2.0. */ -import { eventCategory, sourceIpWithTextMapping } from '../../../mock/enriched_field_metadata'; +import numeral from '@elastic/numeral'; + +import { + ECS_MAPPING_TYPE_EXPECTED, + FIELD, + INDEX_MAPPING_TYPE_ACTUAL, +} from '../../../compare_fields_table/translations'; +import { ERRORS } from '../../data_quality_summary/errors_popover/translations'; +import { ERROR, INDEX, PATTERN } from '../../data_quality_summary/errors_viewer/translations'; +import { + escape, + escapePreserveNewlines, + getAllowedValues, + getCodeFormattedValue, + getCustomMarkdownTableRows, + getDataQualitySummaryMarkdownComment, + getErrorsMarkdownTable, + getErrorsMarkdownTableRows, + getHeaderSeparator, + getIlmExplainPhaseCountsMarkdownComment, + getIncompatibleMappingsMarkdownTableRows, + getIncompatibleValuesMarkdownTableRows, + getIndexInvalidValues, + getMarkdownComment, + getMarkdownTable, + getMarkdownTableHeader, + getPatternSummaryMarkdownComment, + getResultEmoji, + getSameFamilyBadge, + getStatsRollupMarkdownComment, + getSummaryMarkdownComment, + getSummaryTableMarkdownComment, + getSummaryTableMarkdownHeader, + getSummaryTableMarkdownRow, + getTabCountsMarkdownComment, +} from './helpers'; +import { EMPTY_STAT } from '../../../helpers'; +import { mockAllowedValues } from '../../../mock/allowed_values/mock_allowed_values'; +import { + eventCategory, + mockCustomFields, + mockIncompatibleMappings, + sourceIpWithTextMapping, +} from '../../../mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { mockPartitionedFieldMetadata } from '../../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { + auditbeatNoResults, + auditbeatWithAllResults, +} from '../../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; import { SAME_FAMILY } from '../../same_family/translations'; -import { getIncompatibleMappingsMarkdownTableRows, getSameFamilyBadge } from './helpers'; +import { INCOMPATIBLE_FIELD_MAPPINGS_TABLE_TITLE } from '../../tabs/incompatible_tab/translations'; +import { + EnrichedFieldMetadata, + ErrorSummary, + PatternRollup, + UnallowedValueCount, +} from '../../../types'; + +const errorSummary: ErrorSummary[] = [ + { + pattern: '.alerts-security.alerts-default', + indexName: null, + error: 'Error loading stats: Error: Forbidden', + }, + { + error: + 'Error: Error loading unallowed values for index auditbeat-7.2.1-2023.02.13-000001: Forbidden', + indexName: 'auditbeat-7.2.1-2023.02.13-000001', + pattern: 'auditbeat-*', + }, +]; + +const indexName = 'auditbeat-custom-index-1'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; describe('helpers', () => { + describe('escape', () => { + test('it returns undefined when `content` is undefined', () => { + expect(escape(undefined)).toBeUndefined(); + }); + + test("it returns the content unmodified when there's nothing to escape", () => { + const content = "there's nothing to escape in this content"; + expect(escape(content)).toEqual(content); + }); + + test('it replaces all newlines in the content with spaces', () => { + const content = '\nthere were newlines in the beginning, middle,\nand end\n'; + expect(escape(content)).toEqual(' there were newlines in the beginning, middle, and end '); + }); + + test('it escapes all column separators in the content with spaces', () => { + const content = '|there were column separators in the beginning, middle,|and end|'; + expect(escape(content)).toEqual( + '\\|there were column separators in the beginning, middle,\\|and end\\|' + ); + }); + + test('it escapes content containing BOTH newlines and column separators', () => { + const content = + '|\nthere were newlines and column separators in the beginning, middle,\n|and end|\n'; + expect(escape(content)).toEqual( + '\\| there were newlines and column separators in the beginning, middle, \\|and end\\| ' + ); + }); + }); + + describe('escapePreserveNewlines', () => { + test('it returns undefined when `content` is undefined', () => { + expect(escapePreserveNewlines(undefined)).toBeUndefined(); + }); + + test("it returns the content unmodified when there's nothing to escape", () => { + const content = "there's (also) nothing to escape in this content"; + expect(escapePreserveNewlines(content)).toEqual(content); + }); + + test('it escapes all column separators in the content with spaces', () => { + const content = '|there were column separators in the beginning, middle,|and end|'; + expect(escapePreserveNewlines(content)).toEqual( + '\\|there were column separators in the beginning, middle,\\|and end\\|' + ); + }); + + test('it does NOT escape newlines in the content', () => { + const content = + '|\nthere were newlines and column separators in the beginning, middle,\n|and end|\n'; + expect(escapePreserveNewlines(content)).toEqual( + '\\|\nthere were newlines and column separators in the beginning, middle,\n\\|and end\\|\n' + ); + }); + }); + + describe('getHeaderSeparator', () => { + test('it returns a sequence of dashes equal to the length of the header, plus two additional dashes to pad each end of the cntent', () => { + const content = '0123456789'; // content.length === 10 + const expected = '------------'; // expected.length === 12 + + expect(getHeaderSeparator(content)).toEqual(expected); + }); + }); + + describe('getMarkdownTableHeader', () => { + const headerNames = [ + '|\nthere were newlines and column separators in the beginning, middle,\n|and end|\n', + 'A second column', + 'A third column', + ]; + + test('it returns the expected table header', () => { + expect(getMarkdownTableHeader(headerNames)).toEqual( + '\n| \\| there were newlines and column separators in the beginning, middle, \\|and end\\| | A second column | A third column | \n|----------------------------------------------------------------------------------|-----------------|----------------|' + ); + }); + }); + + describe('getCodeFormattedValue', () => { + test('it returns the expected placeholder when `value` is undefined', () => { + expect(getCodeFormattedValue(undefined)).toEqual('`--`'); + }); + + test('it returns the content formatted as markdown code', () => { + const value = 'foozle'; + + expect(getCodeFormattedValue(value)).toEqual('`foozle`'); + }); + + test('it escapes content such that `value` may be included in a markdown table cell', () => { + const value = + '|\nthere were newlines and column separators in the beginning, middle,\n|and end|\n'; + + expect(getCodeFormattedValue(value)).toEqual( + '`\\| there were newlines and column separators in the beginning, middle, \\|and end\\| `' + ); + }); + }); + + describe('getAllowedValues', () => { + test('it returns the expected placeholder when `allowedValues` is undefined', () => { + expect(getAllowedValues(undefined)).toEqual('`--`'); + }); + + test('it joins the `allowedValues` `name`s as a markdown-code-formatted, comma separated, string', () => { + expect(getAllowedValues(mockAllowedValues)).toEqual( + '`authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web`' + ); + }); + }); + + describe('getIndexInvalidValues', () => { + test('it returns the expected placeholder when `indexInvalidValues` is empty', () => { + expect(getIndexInvalidValues([])).toEqual('`--`'); + }); + + test('it returns markdown-code-formatted `fieldName`s, and their associated `count`s', () => { + const indexInvalidValues: UnallowedValueCount[] = [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ]; + + expect(getIndexInvalidValues(indexInvalidValues)).toEqual( + `\`an_invalid_category\` (2), \`theory\` (1)` + ); + }); + }); + + describe('getCustomMarkdownTableRows', () => { + test('it returns the expected table rows', () => { + expect(getCustomMarkdownTableRows(mockCustomFields)).toEqual( + '| host.name.keyword | `keyword` | `--` |\n| some.field | `text` | `--` |\n| some.field.keyword | `keyword` | `--` |\n| source.ip.keyword | `keyword` | `--` |' + ); + }); + + test('it returns the expected table rows when some have allowed values', () => { + const withAllowedValues = [ + ...mockCustomFields, + eventCategory, // note: this is not a real-world use case, because custom fields don't have allowed values + ]; + + expect(getCustomMarkdownTableRows(withAllowedValues)).toEqual( + '| host.name.keyword | `keyword` | `--` |\n| some.field | `text` | `--` |\n| some.field.keyword | `keyword` | `--` |\n| source.ip.keyword | `keyword` | `--` |\n| event.category | `keyword` | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` |' + ); + }); + }); + describe('getSameFamilyBadge', () => { test('it returns the expected badge text when the field is in the same family', () => { const inSameFamily = { @@ -32,7 +265,7 @@ describe('helpers', () => { describe('getIncompatibleMappingsMarkdownTableRows', () => { test('it returns the expected table rows when the field is in the same family', () => { - const eventCategoryWithWildcard = { + const eventCategoryWithWildcard: EnrichedFieldMetadata = { ...eventCategory, // `event.category` is a `keyword` per the ECS spec indexFieldType: 'wildcard', // this index has a mapping of `wildcard` instead of `keyword` isInSameFamily: true, // `wildcard` and `keyword` are in the same family @@ -49,18 +282,367 @@ describe('helpers', () => { }); test('it returns the expected table rows when the field is NOT in the same family', () => { - const eventCategoryWithWildcard = { + const eventCategoryWithText: EnrichedFieldMetadata = { ...eventCategory, // `event.category` is a `keyword` per the ECS spec indexFieldType: 'text', // this index has a mapping of `text` instead of `keyword` isInSameFamily: false, // `text` and `keyword` are NOT in the same family }; expect( - getIncompatibleMappingsMarkdownTableRows([ - eventCategoryWithWildcard, - sourceIpWithTextMapping, - ]) + getIncompatibleMappingsMarkdownTableRows([eventCategoryWithText, sourceIpWithTextMapping]) ).toEqual('| event.category | `keyword` | `text` |\n| source.ip | `ip` | `text` |'); }); }); + + describe('getIncompatibleValuesMarkdownTableRows', () => { + test('it returns the expected table rows', () => { + expect( + getIncompatibleValuesMarkdownTableRows([ + { + ...eventCategory, + hasEcsMetadata: true, + indexInvalidValues: [ + { + count: 2, + fieldName: 'an_invalid_category', + }, + { + count: 1, + fieldName: 'theory', + }, + ], + isEcsCompliant: false, + }, + ]) + ).toEqual( + '| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |' + ); + }); + }); + + describe('getMarkdownComment', () => { + test('it returns the expected markdown comment', () => { + const suggestedAction = + '|\nthere were newlines and column separators in this suggestedAction beginning, middle,\n|and end|\n'; + const title = + '|\nthere were newlines and column separators in this title beginning, middle,\n|and end|\n'; + + expect(getMarkdownComment({ suggestedAction, title })).toEqual( + '#### \\| there were newlines and column separators in this title beginning, middle, \\|and end\\| \n\n\\|\nthere were newlines and column separators in this suggestedAction beginning, middle,\n\\|and end\\|\n' + ); + }); + }); + + describe('getErrorsMarkdownTableRows', () => { + test('it returns the expected markdown table rows', () => { + expect(getErrorsMarkdownTableRows(errorSummary)).toEqual( + '| .alerts-security.alerts-default | -- | `Error loading stats: Error: Forbidden` |\n| auditbeat-* | auditbeat-7.2.1-2023.02.13-000001 | `Error: Error loading unallowed values for index auditbeat-7.2.1-2023.02.13-000001: Forbidden` |' + ); + }); + }); + + describe('getErrorsMarkdownTable', () => { + test('it returns the expected table contents', () => { + expect( + getErrorsMarkdownTable({ + errorSummary, + getMarkdownTableRows: getErrorsMarkdownTableRows, + headerNames: [PATTERN, INDEX, ERROR], + title: ERRORS, + }) + ).toEqual( + `## Errors\n\nSome indices were not checked for Data Quality\n\nErrors may occur when pattern or index metadata is temporarily unavailable, or because you don't have the privileges required for access\n\nThe following privileges are required to check an index:\n- \`monitor\` or \`manage\`\n- \`view_index_metadata\`\n- \`read\`\n\n\n| Pattern | Index | Error | \n|---------|-------|-------|\n| .alerts-security.alerts-default | -- | \`Error loading stats: Error: Forbidden\` |\n| auditbeat-* | auditbeat-7.2.1-2023.02.13-000001 | \`Error: Error loading unallowed values for index auditbeat-7.2.1-2023.02.13-000001: Forbidden\` |\n` + ); + }); + + test('it returns an empty string when the error summary is empty', () => { + expect( + getErrorsMarkdownTable({ + errorSummary: [], // <-- empty + getMarkdownTableRows: getErrorsMarkdownTableRows, + headerNames: [PATTERN, INDEX, ERROR], + title: ERRORS, + }) + ).toEqual(''); + }); + }); + + describe('getMarkdownTable', () => { + test('it returns the expected table contents', () => { + expect( + getMarkdownTable({ + enrichedFieldMetadata: mockIncompatibleMappings, + getMarkdownTableRows: getIncompatibleMappingsMarkdownTableRows, + headerNames: [FIELD, ECS_MAPPING_TYPE_EXPECTED, INDEX_MAPPING_TYPE_ACTUAL], + title: INCOMPATIBLE_FIELD_MAPPINGS_TABLE_TITLE(indexName), + }) + ).toEqual( + '#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n' + ); + }); + + test('it returns an empty string when `enrichedFieldMetadata` is empty', () => { + expect( + getMarkdownTable({ + enrichedFieldMetadata: [], // <-- empty + getMarkdownTableRows: getIncompatibleMappingsMarkdownTableRows, + headerNames: [FIELD, ECS_MAPPING_TYPE_EXPECTED, INDEX_MAPPING_TYPE_ACTUAL], + title: INCOMPATIBLE_FIELD_MAPPINGS_TABLE_TITLE(indexName), + }) + ).toEqual(''); + }); + }); + + describe('getSummaryMarkdownComment', () => { + test('it returns the expected markdown comment', () => { + expect(getSummaryMarkdownComment(indexName)).toEqual('### auditbeat-custom-index-1\n'); + }); + }); + + describe('getTabCountsMarkdownComment', () => { + test('it returns a comment with the expected counts', () => { + expect(getTabCountsMarkdownComment(mockPartitionedFieldMetadata)).toBe( + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n' + ); + }); + }); + + describe('getResultEmoji', () => { + test('it returns the expected placeholder when `incompatible` is undefined', () => { + expect(getResultEmoji(undefined)).toEqual('--'); + }); + + test('it returns a ✅ when the incompatible count is zero', () => { + expect(getResultEmoji(0)).toEqual('✅'); + }); + + test('it returns a ❌ when the incompatible count is NOT zero', () => { + expect(getResultEmoji(1)).toEqual('❌'); + }); + }); + + describe('getSummaryTableMarkdownHeader', () => { + test('it returns the expected header', () => { + expect(getSummaryTableMarkdownHeader()).toEqual( + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|' + ); + }); + }); + + describe('getSummaryTableMarkdownRow', () => { + test('it returns the expected row when all values are provided', () => { + expect( + getSummaryTableMarkdownRow({ + docsCount: 4, + formatBytes, + formatNumber, + incompatible: 3, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual('| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` | 27.7KB |\n'); + }); + + test('it returns the expected row when optional values are NOT provided', () => { + expect( + getSummaryTableMarkdownRow({ + docsCount: 4, + formatBytes, + formatNumber, + incompatible: undefined, // <-- + ilmPhase: undefined, // <-- + indexName: 'auditbeat-custom-index-1', + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual('| -- | auditbeat-custom-index-1 | 4 (0.0%) | -- | -- | 27.7KB |\n'); + }); + }); + + describe('getSummaryTableMarkdownComment', () => { + test('it returns the expected comment', () => { + expect( + getSummaryTableMarkdownComment({ + docsCount: 4, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual( + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` | 27.7KB |\n\n' + ); + }); + }); + + describe('getStatsRollupMarkdownComment', () => { + test('it returns the expected comment', () => { + expect( + getStatsRollupMarkdownComment({ + docsCount: 57410, + formatBytes, + formatNumber, + incompatible: 3, + indices: 25, + indicesChecked: 1, + sizeInBytes: 28413, + }) + ).toEqual( + '| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| 3 | 1 | 25 | 27.7KB | 57,410 |\n' + ); + }); + + test('it returns the expected comment when optional values are undefined', () => { + expect( + getStatsRollupMarkdownComment({ + docsCount: 0, + formatBytes, + formatNumber, + incompatible: undefined, + indices: undefined, + indicesChecked: undefined, + sizeInBytes: undefined, + }) + ).toEqual( + '| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| -- | -- | -- | -- | 0 |\n' + ); + }); + }); + + describe('getDataQualitySummaryMarkdownComment', () => { + test('it returns the expected comment', () => { + expect( + getDataQualitySummaryMarkdownComment({ + formatBytes, + formatNumber, + totalDocsCount: 3343719, + totalIncompatible: 4, + totalIndices: 30, + totalIndicesChecked: 2, + sizeInBytes: 4294967296, + }) + ).toEqual( + '# Data quality\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| 4 | 2 | 30 | 4GB | 3,343,719 |\n\n' + ); + }); + + test('it returns the expected comment when optional values are undefined', () => { + expect( + getDataQualitySummaryMarkdownComment({ + formatBytes, + formatNumber, + totalDocsCount: undefined, + totalIncompatible: undefined, + totalIndices: undefined, + totalIndicesChecked: undefined, + sizeInBytes: undefined, + }) + ).toEqual( + '# Data quality\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| -- | -- | -- | -- | 0 |\n\n' + ); + }); + }); + + describe('getIlmExplainPhaseCountsMarkdownComment', () => { + test('it returns the expected comment when _all_ of the counts are greater than zero', () => { + expect( + getIlmExplainPhaseCountsMarkdownComment({ + hot: 99, + warm: 8, + unmanaged: 77, + cold: 6, + frozen: 55, + }) + ).toEqual('`hot(99)` `warm(8)` `unmanaged(77)` `cold(6)` `frozen(55)`'); + }); + + test('it returns the expected comment when _some_ of the counts are greater than zero', () => { + expect( + getIlmExplainPhaseCountsMarkdownComment({ + hot: 9, + warm: 0, + unmanaged: 2, + cold: 1, + frozen: 0, + }) + ).toEqual('`hot(9)` `unmanaged(2)` `cold(1)`'); + }); + + test('it returns the expected comment when _none_ of the counts are greater than zero', () => { + expect( + getIlmExplainPhaseCountsMarkdownComment({ + hot: 0, + warm: 0, + unmanaged: 0, + cold: 0, + frozen: 0, + }) + ).toEqual(''); + }); + }); + + describe('getPatternSummaryMarkdownComment', () => { + test('it returns the expected comment when the rollup contains results for all of the indices in the pattern', () => { + expect( + getPatternSummaryMarkdownComment({ + formatBytes, + formatNumber, + patternRollup: auditbeatWithAllResults, + }) + ).toEqual( + '## auditbeat-*\n`hot(1)` `unmanaged(2)`\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| 4 | 3 | 3 | 17.9MB | 19,127 |\n\n' + ); + }); + + test('it returns the expected comment when the rollup contains no results', () => { + expect( + getPatternSummaryMarkdownComment({ + formatBytes, + formatNumber, + patternRollup: auditbeatNoResults, + }) + ).toEqual( + '## auditbeat-*\n`hot(1)` `unmanaged(2)`\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| -- | 0 | 3 | 17.9MB | 19,127 |\n\n' + ); + }); + + test('it returns the expected comment when the rollup does NOT have `ilmExplainPhaseCounts`', () => { + const noIlmExplainPhaseCounts: PatternRollup = { + ...auditbeatWithAllResults, + ilmExplainPhaseCounts: undefined, // <-- + }; + + expect( + getPatternSummaryMarkdownComment({ + formatBytes, + formatNumber, + patternRollup: noIlmExplainPhaseCounts, + }) + ).toEqual( + '## auditbeat-*\n\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| 4 | 3 | 3 | 17.9MB | 19,127 |\n\n' + ); + }); + + test('it returns the expected comment when `docsCount` is undefined', () => { + const noDocsCount: PatternRollup = { + ...auditbeatWithAllResults, + docsCount: undefined, // <-- + }; + + expect( + getPatternSummaryMarkdownComment({ + formatBytes, + formatNumber, + patternRollup: noDocsCount, + }) + ).toEqual( + '## auditbeat-*\n`hot(1)` `unmanaged(2)`\n\n| Incompatible fields | Indices checked | Indices | Size | Docs |\n|---------------------|-----------------|---------|------|------|\n| 4 | 3 | 3 | 17.9MB | 0 |\n\n' + ); + }); + }); }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.ts index 9137bb346d660..32424dff66883 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/index_properties/markdown/helpers.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { repeat } from 'lodash/fp'; - import { ERRORS_MAY_OCCUR, ERRORS_CALLOUT_SUMMARY, @@ -44,6 +42,7 @@ import { INDICES, INDICES_CHECKED, RESULT, + SIZE, } from '../../summary_table/translations'; import { DATA_QUALITY_TITLE } from '../../../translations'; @@ -65,11 +64,11 @@ export const escape = (content: string | undefined): string | undefined => export const escapePreserveNewlines = (content: string | undefined): string | undefined => content != null ? content.replaceAll('|', '\\|') : content; -export const getHeaderSeparator = (headerLength: number): string => repeat(headerLength + 2, '-'); +export const getHeaderSeparator = (headerText: string): string => '-'.repeat(headerText.length + 2); // 2 extra, for the spaces on both sides of the column name export const getMarkdownTableHeader = (headerNames: string[]) => ` | ${headerNames.map((name) => `${escape(name)} | `).join('')} -|${headerNames.map((name) => `${getHeaderSeparator(name.length)}|`).join('')}`; +|${headerNames.map((name) => `${getHeaderSeparator(name)}|`).join('')}`; export const getCodeFormattedValue = (value: string | undefined) => `\`${escape(value ?? EMPTY_PLACEHOLDER)}\``; @@ -84,21 +83,7 @@ export const getIndexInvalidValues = (indexInvalidValues: UnallowedValueCount[]) ? getCodeFormattedValue(undefined) : indexInvalidValues .map(({ fieldName, count }) => `${getCodeFormattedValue(escape(fieldName))} (${count})`) - .join(',\n'); - -export const getCommonMarkdownTableRows = ( - enrichedFieldMetadata: EnrichedFieldMetadata[] -): string => - enrichedFieldMetadata - .map( - (x) => - `| ${escape(x.indexFieldName)} | ${getCodeFormattedValue(x.type)} | ${getCodeFormattedValue( - x.indexFieldType - )} | ${getAllowedValues(x.allowed_values)} | ${getIndexInvalidValues( - x.indexInvalidValues - )} | ${escape(x.description ?? EMPTY_PLACEHOLDER)} |` - ) - .join('\n'); + .join(', '); // newlines are instead joined with spaces export const getCustomMarkdownTableRows = ( enrichedFieldMetadata: EnrichedFieldMetadata[] @@ -207,19 +192,7 @@ ${getMarkdownTableRows(enrichedFieldMetadata)} ` : ''; -export const getSummaryMarkdownComment = ({ - ecsFieldReferenceUrl, - ecsReferenceUrl, - incompatible, - indexName, - mappingUrl, -}: { - ecsFieldReferenceUrl: string; - ecsReferenceUrl: string; - incompatible: number | undefined; - indexName: string; - mappingUrl: string; -}): string => +export const getSummaryMarkdownComment = (indexName: string) => `### ${escape(indexName)} `; @@ -244,23 +217,31 @@ export const getResultEmoji = (incompatible: number | undefined): string => { }; export const getSummaryTableMarkdownHeader = (): string => - `| ${RESULT} | ${INDEX} | ${DOCS} | ${INCOMPATIBLE_FIELDS} | ${ILM_PHASE} | -|--------|-------|------|---------------------|-----------|`; + `| ${RESULT} | ${INDEX} | ${DOCS} | ${INCOMPATIBLE_FIELDS} | ${ILM_PHASE} | ${SIZE} | +|${getHeaderSeparator(RESULT)}|${getHeaderSeparator(INDEX)}|${getHeaderSeparator( + DOCS + )}|${getHeaderSeparator(INCOMPATIBLE_FIELDS)}|${getHeaderSeparator( + ILM_PHASE + )}|${getHeaderSeparator(SIZE)}|`; export const getSummaryTableMarkdownRow = ({ docsCount, + formatBytes, formatNumber, ilmPhase, incompatible, indexName, patternDocsCount, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; incompatible: number | undefined; indexName: string; patternDocsCount: number; + sizeInBytes: number | undefined; }): string => `| ${getResultEmoji(incompatible)} | ${escape(indexName)} | ${formatNumber( docsCount @@ -268,77 +249,95 @@ export const getSummaryTableMarkdownRow = ({ docsCount, patternDocsCount, })}) | ${incompatible ?? EMPTY_PLACEHOLDER} | ${ - ilmPhase != null ? getCodeFormattedValue(ilmPhase) : '' - } | + ilmPhase != null ? getCodeFormattedValue(ilmPhase) : EMPTY_PLACEHOLDER + } | ${formatBytes(sizeInBytes)} | `; export const getSummaryTableMarkdownComment = ({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata; patternDocsCount: number; + sizeInBytes: number | undefined; }): string => `${getSummaryTableMarkdownHeader()} ${getSummaryTableMarkdownRow({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, incompatible: partitionedFieldMetadata.incompatible.length, patternDocsCount, + sizeInBytes, })} `; export const getStatsRollupMarkdownComment = ({ docsCount, + formatBytes, formatNumber, incompatible, indices, indicesChecked, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; incompatible: number | undefined; indices: number | undefined; indicesChecked: number | undefined; + sizeInBytes: number | undefined; }): string => - `| ${INCOMPATIBLE_FIELDS} | ${INDICES_CHECKED} | ${INDICES} | ${DOCS} | -|---------------------|-----------------|---------|------| + `| ${INCOMPATIBLE_FIELDS} | ${INDICES_CHECKED} | ${INDICES} | ${SIZE} | ${DOCS} | +|${getHeaderSeparator(INCOMPATIBLE_FIELDS)}|${getHeaderSeparator( + INDICES_CHECKED + )}|${getHeaderSeparator(INDICES)}|${getHeaderSeparator(SIZE)}|${getHeaderSeparator(DOCS)}| | ${incompatible ?? EMPTY_STAT} | ${indicesChecked ?? EMPTY_STAT} | ${ indices ?? EMPTY_STAT - } | ${formatNumber(docsCount)} | + } | ${formatBytes(sizeInBytes)} | ${formatNumber(docsCount)} | `; export const getDataQualitySummaryMarkdownComment = ({ + formatBytes, formatNumber, totalDocsCount, totalIncompatible, totalIndices, totalIndicesChecked, + sizeInBytes, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; totalDocsCount: number | undefined; totalIncompatible: number | undefined; totalIndices: number | undefined; totalIndicesChecked: number | undefined; + sizeInBytes: number | undefined; }): string => `# ${DATA_QUALITY_TITLE} ${getStatsRollupMarkdownComment({ docsCount: totalDocsCount ?? 0, + formatBytes, formatNumber, incompatible: totalIncompatible, indices: totalIndices, indicesChecked: totalIndicesChecked, + sizeInBytes, })} `; @@ -355,13 +354,17 @@ export const getIlmExplainPhaseCountsMarkdownComment = ({ unmanaged > 0 ? getCodeFormattedValue(`${UNMANAGED}(${unmanaged})`) : '', cold > 0 ? getCodeFormattedValue(`${COLD}(${cold})`) : '', frozen > 0 ? getCodeFormattedValue(`${FROZEN}(${frozen})`) : '', - ].join(' '); + ] + .filter((x) => x !== '') // prevents extra whitespace + .join(' '); export const getPatternSummaryMarkdownComment = ({ + formatBytes, formatNumber, patternRollup, patternRollup: { docsCount, indices, ilmExplainPhaseCounts, pattern, results }, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; patternRollup: PatternRollup; }): string => @@ -374,9 +377,11 @@ ${ ${getStatsRollupMarkdownComment({ docsCount: docsCount ?? 0, + formatBytes, formatNumber, incompatible: getTotalPatternIncompatible(results), indices, indicesChecked: getTotalPatternIndicesChecked(patternRollup), + sizeInBytes: patternRollup.sizeInBytes, })} `; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/loading_empty_prompt/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/loading_empty_prompt/index.tsx index 84e701c0aa244..7eda6d039ffe0 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/loading_empty_prompt/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/loading_empty_prompt/index.tsx @@ -15,7 +15,9 @@ interface Props { const LoadingEmptyPromptComponent: React.FC = ({ loading }) => { const icon = useMemo(() => , []); - return {loading}} />; + return ( + {loading}} /> + ); }; LoadingEmptyPromptComponent.displayName = 'LoadingEmptyPromptComponent'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/panel_subtitle/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/panel_subtitle/index.tsx deleted file mode 100644 index 10e460d9c24f5..0000000000000 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/panel_subtitle/index.tsx +++ /dev/null @@ -1,34 +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 { EuiCode, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React from 'react'; - -import * as i18n from '../../translations'; - -interface Props { - error: string | null; - loading: boolean; - version: string | null; - versionLoading: boolean; -} - -const PanelSubtitleComponent: React.FC = ({ error, loading, version, versionLoading }) => { - const allDataLoaded = !loading && !versionLoading && error == null && version != null; - - return allDataLoaded ? ( - - - - {i18n.SELECT_AN_INDEX} {version} - - - - ) : null; -}; - -export const PanelSubtitle = React.memo(PanelSubtitleComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.test.ts new file mode 100644 index 0000000000000..c40f6a73ffa03 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.test.ts @@ -0,0 +1,865 @@ +/* + * 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 { + IlmExplainLifecycleLifecycleExplain, + IlmExplainLifecycleLifecycleExplainManaged, + IlmExplainLifecycleLifecycleExplainUnmanaged, +} from '@elastic/elasticsearch/lib/api/types'; + +import { + defaultSort, + getIlmPhase, + getIndexPropertiesContainerId, + getIlmExplainPhaseCounts, + getIndexIncompatible, + getPageIndex, + getPhaseCount, + getSummaryTableItems, + isManaged, + shouldCreateIndexNames, + shouldCreatePatternRollup, +} from './helpers'; +import { mockIlmExplain } from '../../mock/ilm_explain/mock_ilm_explain'; +import { mockDataQualityCheckResult } from '../../mock/data_quality_check_result/mock_index'; +import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { mockStats } from '../../mock/stats/mock_stats'; +import { IndexSummaryTableItem } from '../summary_table/helpers'; +import { DataQualityCheckResult } from '../../types'; + +const hot: IlmExplainLifecycleLifecycleExplainManaged = { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '3.98d', + lifecycle_date_millis: 1675536751379, + age: '3.98d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, +}; +const warm = { + ...hot, + phase: 'warm', +}; +const cold = { + ...hot, + phase: 'cold', +}; +const frozen = { + ...hot, + phase: 'frozen', +}; +const other = { + ...hot, + phase: 'other', // not a valid phase +}; + +const managed: Record = { + hot, + warm, + cold, + frozen, +}; + +const unmanaged: IlmExplainLifecycleLifecycleExplainUnmanaged = { + index: 'michael', + managed: false, +}; + +describe('helpers', () => { + const indexName = '.ds-packetbeat-8.6.1-2023.02.04-000001'; + + describe('isManaged', () => { + test('it returns true when the `ilmExplainRecord` `managed` property is true', () => { + const ilmExplain = mockIlmExplain[indexName]; + + expect(isManaged(ilmExplain)).toBe(true); + }); + + test('it returns false when the `ilmExplainRecord` is undefined', () => { + expect(isManaged(undefined)).toBe(false); + }); + }); + + describe('getPhaseCount', () => { + test('it returns the expected count when an index with the specified `ilmPhase` exists in the `IlmExplainLifecycleLifecycleExplain` record', () => { + expect( + getPhaseCount({ + ilmExplain: mockIlmExplain, + ilmPhase: 'hot', // this phase is in the record + indexName, // valid index name + }) + ).toEqual(1); + }); + + test('it returns zero when `ilmPhase` is null', () => { + expect( + getPhaseCount({ + ilmExplain: null, + ilmPhase: 'hot', + indexName, + }) + ).toEqual(0); + }); + + test('it returns zero when the `indexName` does NOT exist in the `IlmExplainLifecycleLifecycleExplain` record', () => { + expect( + getPhaseCount({ + ilmExplain: mockIlmExplain, + ilmPhase: 'hot', + indexName: 'invalid', // this index does NOT exist + }) + ).toEqual(0); + }); + + test('it returns zero when the specified `ilmPhase` does NOT exist in the `IlmExplainLifecycleLifecycleExplain` record', () => { + expect( + getPhaseCount({ + ilmExplain: mockIlmExplain, + ilmPhase: 'warm', // this phase is NOT in the record + indexName, // valid index name + }) + ).toEqual(0); + }); + + describe('when `ilmPhase` is `unmanaged`', () => { + test('it returns the expected count for an `unmanaged` index', () => { + const index = 'auditbeat-custom-index-1'; + const ilmExplainRecord: IlmExplainLifecycleLifecycleExplain = { + index, + managed: false, + }; + const ilmExplain = { + [index]: ilmExplainRecord, + }; + + expect( + getPhaseCount({ + ilmExplain, + ilmPhase: 'unmanaged', // ilmPhase is unmanaged + indexName: index, // an unmanaged index + }) + ).toEqual(1); + }); + + test('it returns zero for a managed index', () => { + expect( + getPhaseCount({ + ilmExplain: mockIlmExplain, + ilmPhase: 'unmanaged', // ilmPhase is unmanaged + indexName, // a managed (`hot`) index + }) + ).toEqual(0); + }); + }); + }); + + describe('getIlmPhase', () => { + test('it returns undefined when the `ilmExplainRecord` is undefined', () => { + expect(getIlmPhase(undefined)).toBeUndefined(); + }); + + describe('when the `ilmExplainRecord` is a `IlmExplainLifecycleLifecycleExplainManaged` record', () => { + Object.keys(managed).forEach((phase) => + test(`it returns the expected phase when 'phase' is '${phase}'`, () => { + expect(getIlmPhase(managed[phase])).toEqual(phase); + }) + ); + + test(`it returns undefined when the 'phase' is unknown`, () => { + expect(getIlmPhase(other)).toBeUndefined(); + }); + }); + + describe('when the `ilmExplainRecord` is a `IlmExplainLifecycleLifecycleExplainUnmanaged` record', () => { + test('it returns `unmanaged`', () => { + expect(getIlmPhase(unmanaged)).toEqual('unmanaged'); + }); + }); + }); + + describe('getIlmExplainPhaseCounts', () => { + test('it returns the expected counts (all zeros) when `ilmExplain` is null', () => { + expect(getIlmExplainPhaseCounts(null)).toEqual({ + cold: 0, + frozen: 0, + hot: 0, + unmanaged: 0, + warm: 0, + }); + }); + + test('it returns the expected counts', () => { + const ilmExplain: Record = { + ...managed, + [unmanaged.index]: unmanaged, + }; + + expect(getIlmExplainPhaseCounts(ilmExplain)).toEqual({ + cold: 1, + frozen: 1, + hot: 1, + unmanaged: 1, + warm: 1, + }); + }); + }); + + describe('getIndexIncompatible', () => { + test('it returns undefined when `results` is undefined', () => { + expect( + getIndexIncompatible({ + indexName, + results: undefined, // <-- + }) + ).toBeUndefined(); + }); + + test('it returns undefined when `indexName` is not in the `results`', () => { + expect( + getIndexIncompatible({ + indexName: 'not_in_the_results', // <-- + results: mockDataQualityCheckResult, + }) + ).toBeUndefined(); + }); + + test('it returns the expected count', () => { + expect( + getIndexIncompatible({ + indexName: 'auditbeat-custom-index-1', + results: mockDataQualityCheckResult, + }) + ).toEqual(3); + }); + }); + + describe('getSummaryTableItems', () => { + const indexNames = [ + '.ds-packetbeat-8.6.1-2023.02.04-000001', + '.ds-packetbeat-8.5.3-2023.02.04-000001', + 'auditbeat-custom-index-1', + ]; + const pattern = 'auditbeat-*'; + const patternDocsCount = 4; + const results: Record = { + 'auditbeat-custom-index-1': { + docsCount: 4, + error: null, + ilmPhase: 'unmanaged', + incompatible: 3, + indexName: 'auditbeat-custom-index-1', + markdownComments: [ + '### auditbeat-custom-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase |\n|--------|-------|------|---------------------|-----------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2),\n`theory` (1) |\n\n', + ], + pattern: 'auditbeat-*', + }, + }; + + test('it returns the expected summary table items', () => { + expect( + getSummaryTableItems({ + ilmExplain: mockIlmExplain, + indexNames, + pattern, + patternDocsCount, + results, + sortByColumn: defaultSort.sort.field, + sortByDirection: defaultSort.sort.direction, + stats: mockStats, + }) + ).toEqual([ + { + docsCount: 1630289, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 733175040, + }, + { + docsCount: 1628343, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 731583142, + }, + { + docsCount: 4, + ilmPhase: 'unmanaged', + incompatible: 3, + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 28413, + }, + ]); + }); + + test('it returns the expected summary table items when `sortByDirection` is ascending', () => { + expect( + getSummaryTableItems({ + ilmExplain: mockIlmExplain, + indexNames, + pattern, + patternDocsCount, + results, + sortByColumn: defaultSort.sort.field, + sortByDirection: 'asc', // <-- ascending + stats: mockStats, + }) + ).toEqual([ + { + docsCount: 4, + ilmPhase: 'unmanaged', + incompatible: 3, + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 28413, + }, + { + docsCount: 1628343, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 731583142, + }, + { + docsCount: 1630289, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 733175040, + }, + ]); + }); + + test('it returns the expected summary table items when data is unavailable', () => { + expect( + getSummaryTableItems({ + ilmExplain: null, // <-- no data + indexNames, + pattern, + patternDocsCount, + results: undefined, // <-- no data + sortByColumn: defaultSort.sort.field, + sortByDirection: defaultSort.sort.direction, + stats: null, // <-- no data + }) + ).toEqual([ + { + docsCount: 0, + ilmPhase: undefined, + incompatible: undefined, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 0, + }, + { + docsCount: 0, + ilmPhase: undefined, + incompatible: undefined, + indexName: '.ds-packetbeat-8.5.3-2023.02.04-000001', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 0, + }, + { + docsCount: 0, + ilmPhase: undefined, + incompatible: undefined, + indexName: 'auditbeat-custom-index-1', + pattern: 'auditbeat-*', + patternDocsCount: 4, + sizeInBytes: 0, + }, + ]); + }); + }); + + describe('shouldCreateIndexNames', () => { + const indexNames = [ + '.ds-packetbeat-8.6.1-2023.02.04-000001', + '.ds-packetbeat-8.5.3-2023.02.04-000001', + 'auditbeat-custom-index-1', + ]; + + test('returns true when `indexNames` does NOT exist, and the required `stats` and `ilmExplain` are available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: mockIlmExplain, + indexNames: undefined, + stats: mockStats, + }) + ).toBe(true); + }); + + test('returns false when `indexNames` exists, and the required `stats` and `ilmExplain` are available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: mockIlmExplain, + indexNames, + stats: mockStats, + }) + ).toBe(false); + }); + + test('returns false when `indexNames` does NOT exist, `stats` is NOT available, and `ilmExplain` is available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: mockIlmExplain, + indexNames: undefined, + stats: null, + }) + ).toBe(false); + }); + + test('returns false when `indexNames` does NOT exist, `stats` is available, and `ilmExplain` is NOT available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: null, + indexNames: undefined, + stats: mockStats, + }) + ).toBe(false); + }); + + test('returns false when `indexNames` does NOT exist, `stats` is NOT available, and `ilmExplain` is NOT available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: null, + indexNames: undefined, + stats: null, + }) + ).toBe(false); + }); + + test('returns false when `indexNames` exists, `stats` is NOT available, and `ilmExplain` is NOT available', () => { + expect( + shouldCreateIndexNames({ + ilmExplain: null, + indexNames, + stats: null, + }) + ).toBe(false); + }); + }); + + describe('shouldCreatePatternRollup', () => { + test('it returns false when the `patternRollup` already exists', () => { + expect( + shouldCreatePatternRollup({ + error: null, + ilmExplain: mockIlmExplain, + patternRollup: auditbeatWithAllResults, + stats: mockStats, + }) + ).toBe(false); + }); + + test('it returns true when all data was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: null, + ilmExplain: mockIlmExplain, + patternRollup: undefined, + stats: mockStats, + }) + ).toBe(true); + }); + + test('it returns false when `stats`, but NOT `ilmExplain` was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: null, + ilmExplain: null, + patternRollup: undefined, + stats: mockStats, + }) + ).toBe(false); + }); + + test('it returns false when `stats` was NOT loaded, and `ilmExplain` was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: null, + ilmExplain: mockIlmExplain, + patternRollup: undefined, + stats: null, + }) + ).toBe(false); + }); + + test('it returns true if an error occurred, and NO data was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: 'whoops', + ilmExplain: null, + patternRollup: undefined, + stats: null, + }) + ).toBe(true); + }); + + test('it returns true if an error occurred, and just `stats` was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: 'something went', + ilmExplain: null, + patternRollup: undefined, + stats: mockStats, + }) + ).toBe(true); + }); + + test('it returns true if an error occurred, and just `ilmExplain` was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: 'horribly wrong', + ilmExplain: mockIlmExplain, + patternRollup: undefined, + stats: null, + }) + ).toBe(true); + }); + + test('it returns true if an error occurred, and all data was loaded', () => { + expect( + shouldCreatePatternRollup({ + error: 'over here', + ilmExplain: mockIlmExplain, + patternRollup: undefined, + stats: mockStats, + }) + ).toBe(true); + }); + }); + + describe('getIndexPropertiesContainerId', () => { + const pattern = 'auditbeat-*'; + + test('it returns the expected id', () => { + expect(getIndexPropertiesContainerId({ indexName, pattern })).toEqual( + 'index-properties-container-auditbeat-*.ds-packetbeat-8.6.1-2023.02.04-000001' + ); + }); + }); + + describe('getPageIndex', () => { + const getPageIndexArgs: { + indexName: string; + items: IndexSummaryTableItem[]; + pageSize: number; + } = { + indexName: 'auditbeat-7.17.9-2023.04.09-000001', // <-- on page 2 of 3 (page index 1) + items: [ + { + docsCount: 48077, + incompatible: undefined, + indexName: 'auditbeat-7.14.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 43357342, + }, + { + docsCount: 48068, + incompatible: undefined, + indexName: 'auditbeat-7.3.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 32460397, + }, + { + docsCount: 48064, + incompatible: undefined, + indexName: 'auditbeat-7.11.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 42782794, + }, + { + docsCount: 47868, + incompatible: undefined, + indexName: 'auditbeat-7.6.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 31575964, + }, + { + docsCount: 47827, + incompatible: 20, + indexName: 'auditbeat-7.15.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 44130657, + }, + { + docsCount: 47642, + incompatible: undefined, + indexName: '.ds-auditbeat-8.4.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 42412521, + }, + { + docsCount: 47545, + incompatible: undefined, + indexName: 'auditbeat-7.16.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41423244, + }, + { + docsCount: 47531, + incompatible: undefined, + indexName: 'auditbeat-7.5.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 32394133, + }, + { + docsCount: 47530, + incompatible: undefined, + indexName: 'auditbeat-7.12.1-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 43015519, + }, + { + docsCount: 47520, + incompatible: undefined, + indexName: '.ds-auditbeat-8.0.1-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 42230604, + }, + { + docsCount: 47496, + incompatible: undefined, + indexName: '.ds-auditbeat-8.2.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41710968, + }, + { + docsCount: 47486, + incompatible: undefined, + indexName: '.ds-auditbeat-8.5.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 42295944, + }, + { + docsCount: 47486, + incompatible: undefined, + indexName: '.ds-auditbeat-8.3.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41761321, + }, + { + docsCount: 47460, + incompatible: undefined, + indexName: 'auditbeat-7.2.1-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 30481198, + }, + { + docsCount: 47439, + incompatible: undefined, + indexName: 'auditbeat-7.17.9-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41554041, + }, + { + docsCount: 47395, + incompatible: undefined, + indexName: 'auditbeat-7.9.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 42815907, + }, + { + docsCount: 47394, + incompatible: undefined, + indexName: '.ds-auditbeat-8.7.0-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41157112, + }, + { + docsCount: 47372, + incompatible: undefined, + indexName: 'auditbeat-7.4.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 31626792, + }, + { + docsCount: 47369, + incompatible: undefined, + indexName: 'auditbeat-7.13.4-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41828969, + }, + { + docsCount: 47348, + incompatible: undefined, + indexName: 'auditbeat-7.7.1-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 40010773, + }, + { + docsCount: 47339, + incompatible: undefined, + indexName: 'auditbeat-7.10.2-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 43480570, + }, + { + docsCount: 47325, + incompatible: undefined, + indexName: '.ds-auditbeat-8.1.3-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 41822475, + }, + { + docsCount: 47294, + incompatible: undefined, + indexName: 'auditbeat-7.8.0-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 43018490, + }, + { + docsCount: 24276, + incompatible: undefined, + indexName: '.ds-auditbeat-8.6.1-2023.04.09-000001', + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 23579440, + }, + { + docsCount: 4, + incompatible: undefined, + indexName: 'auditbeat-custom-index-1', + ilmPhase: 'unmanaged', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 28409, + }, + { + docsCount: 0, + incompatible: undefined, + indexName: 'auditbeat-custom-empty-index-1', + ilmPhase: 'unmanaged', + pattern: 'auditbeat-*', + patternDocsCount: 1118155, + sizeInBytes: 247, + }, + ], + pageSize: 10, + }; + + test('it returns the expected page index', () => { + expect(getPageIndex(getPageIndexArgs)).toEqual(1); + }); + + test('it returns the expected page index for the first item', () => { + const firstItemIndexName = 'auditbeat-7.14.2-2023.04.09-000001'; + + expect( + getPageIndex({ + ...getPageIndexArgs, + indexName: firstItemIndexName, + }) + ).toEqual(0); + }); + + test('it returns the expected page index for the last item', () => { + const lastItemIndexName = 'auditbeat-custom-empty-index-1'; + + expect( + getPageIndex({ + ...getPageIndexArgs, + indexName: lastItemIndexName, + }) + ).toEqual(2); + }); + + test('it returns null when the index cannot be found', () => { + expect( + getPageIndex({ + ...getPageIndexArgs, + indexName: 'does_not_exist', // <-- this index is not in the items + }) + ).toBeNull(); + }); + + test('it returns null when `pageSize` is zero', () => { + expect( + getPageIndex({ + ...getPageIndexArgs, + pageSize: 0, // <-- invalid + }) + ).toBeNull(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.ts index efa9dbfa69d5e..40d0bbfb26293 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/helpers.ts @@ -9,7 +9,7 @@ import type { IlmExplainLifecycleLifecycleExplain, IndicesStatsIndicesStats, } from '@elastic/elasticsearch/lib/api/types'; -import { sortBy } from 'lodash/fp'; +import { orderBy } from 'lodash/fp'; import type { IndexSummaryTableItem } from '../summary_table/helpers'; import type { @@ -17,8 +17,9 @@ import type { IlmExplainPhaseCounts, DataQualityCheckResult, PatternRollup, + SortConfig, } from '../../types'; -import { getDocsCount } from '../../helpers'; +import { getDocsCount, getSizeInBytes } from '../../helpers'; export const isManaged = ( ilmExplainRecord: IlmExplainLifecycleLifecycleExplain | undefined @@ -144,6 +145,8 @@ export const getSummaryTableItems = ({ pattern, patternDocsCount, results, + sortByColumn, + sortByDirection, stats, }: { ilmExplain: Record | null; @@ -151,6 +154,8 @@ export const getSummaryTableItems = ({ pattern: string; patternDocsCount: number; results: Record | undefined; + sortByColumn: string; + sortByDirection: 'desc' | 'asc'; stats: Record | null; }): IndexSummaryTableItem[] => { const summaryTableItems = indexNames.map((indexName) => ({ @@ -160,47 +165,10 @@ export const getSummaryTableItems = ({ ilmPhase: ilmExplain != null ? getIlmPhase(ilmExplain[indexName]) : undefined, pattern, patternDocsCount, + sizeInBytes: getSizeInBytes({ stats, indexName }), })); - return sortBy('docsCount', summaryTableItems).reverse(); -}; - -export const getDefaultIndexIncompatibleCounts = ( - indexNames: string[] -): Record => - indexNames.reduce>( - (acc, indexName) => ({ - ...acc, - [indexName]: undefined, - }), - {} - ); - -export const createPatternIncompatibleEntries = ({ - indexNames, - patternIncompatible, -}: { - indexNames: string[]; - patternIncompatible: Record; -}): Record => - indexNames.reduce>( - (acc, indexName) => - indexName in patternIncompatible - ? { ...acc, [indexName]: patternIncompatible[indexName] } - : { ...acc, [indexName]: undefined }, - {} - ); - -export const getIncompatible = ( - patternIncompatible: Record -): number | undefined => { - const allIndexes = Object.values(patternIncompatible); - const allIndexesHaveValues = allIndexes.every((incompatible) => Number.isInteger(incompatible)); - - // only return a number when all indexes have an `incompatible` count: - return allIndexesHaveValues - ? allIndexes.reduce((acc, incompatible) => acc + Number(incompatible), 0) - : undefined; + return orderBy([sortByColumn], [sortByDirection], summaryTableItems); }; export const shouldCreateIndexNames = ({ @@ -233,3 +201,38 @@ export const shouldCreatePatternRollup = ({ return allDataLoaded || errorOccurred; }; + +export const getIndexPropertiesContainerId = ({ + indexName, + pattern, +}: { + indexName: string; + pattern: string; +}): string => `index-properties-container-${pattern}${indexName}`; + +export const defaultSort: SortConfig = { + sort: { + direction: 'desc', + field: 'docsCount', + }, +}; + +export const MIN_PAGE_SIZE = 10; + +export const getPageIndex = ({ + indexName, + items, + pageSize, +}: { + indexName: string; + items: IndexSummaryTableItem[]; + pageSize: number; +}): number | null => { + const index = items.findIndex((x) => x.indexName === indexName); + + if (index !== -1 && pageSize !== 0) { + return Math.floor(index / pageSize); + } else { + return null; + } +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx index 4fa3b34884929..d9b002a63dc68 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.test.tsx @@ -6,12 +6,22 @@ */ import { DARK_THEME } from '@elastic/charts'; +import numeral from '@elastic/numeral'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../mock/test_providers'; +import { EMPTY_STAT } from '../../helpers'; +import { TestProviders } from '../../mock/test_providers/test_providers'; import { Pattern } from '.'; +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + jest.mock('../../use_stats', () => ({ useStats: jest.fn(() => ({ stats: {}, @@ -31,12 +41,15 @@ jest.mock('../../use_ilm_explain', () => ({ const defaultProps = { addSuccessToast: jest.fn(), canUserCreateAndReadCases: jest.fn(), - defaultNumberFormat: '0,0.[000]', + formatBytes, + formatNumber, getGroupByFieldsOnClick: jest.fn(), ilmPhases: ['hot', 'warm', 'unmanaged'], indexNames: undefined, openCreateCaseFlyout: jest.fn(), patternRollup: undefined, + selectedIndex: null, + setSelectedIndex: jest.fn(), theme: DARK_THEME, updatePatternIndexNames: jest.fn(), updatePatternRollup: jest.fn(), diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx index bd6479490dab8..cb430d75ef12e 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/index.tsx @@ -16,14 +16,17 @@ import type { } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled from 'styled-components'; import { ErrorEmptyPrompt } from '../error_empty_prompt'; import { + defaultSort, getIlmExplainPhaseCounts, getIlmPhase, + getPageIndex, getSummaryTableItems, + MIN_PAGE_SIZE, shouldCreateIndexNames, shouldCreatePatternRollup, } from './helpers'; @@ -33,6 +36,7 @@ import { getTotalDocsCount, getTotalPatternIncompatible, getTotalPatternIndicesChecked, + getTotalSizeInBytes, } from '../../helpers'; import { IndexProperties } from '../index_properties'; import { LoadingEmptyPrompt } from '../loading_empty_prompt'; @@ -41,7 +45,7 @@ import { RemoteClustersCallout } from '../remote_clusters_callout'; import { SummaryTable } from '../summary_table'; import { getSummaryTableColumns } from '../summary_table/helpers'; import * as i18n from './translations'; -import type { PatternRollup } from '../../types'; +import type { PatternRollup, SelectedIndex, SortConfig } from '../../types'; import { useIlmExplain } from '../../use_ilm_explain'; import { useStats } from '../../use_stats'; @@ -55,7 +59,8 @@ const EMPTY_INDEX_NAMES: string[] = []; interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; getGroupByFieldsOnClick: ( elements: Array< | FlameElementEvent @@ -80,6 +85,8 @@ interface Props { }) => void; pattern: string; patternRollup: PatternRollup | undefined; + selectedIndex: SelectedIndex | null; + setSelectedIndex: (selectedIndex: SelectedIndex | null) => void; theme: Theme; updatePatternIndexNames: ({ indexNames, @@ -94,17 +101,25 @@ interface Props { const PatternComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, getGroupByFieldsOnClick, indexNames, ilmPhases, openCreateCaseFlyout, pattern, patternRollup, + selectedIndex, + setSelectedIndex, theme, updatePatternIndexNames, updatePatternRollup, }) => { + const containerRef = useRef(null); + const [sorting, setSorting] = useState(defaultSort); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); + const { error: statsError, loading: loadingStats, stats } = useStats(pattern); const { error: ilmExplainError, loading: loadingIlmExplain, ilmExplain } = useIlmExplain(pattern); @@ -129,7 +144,8 @@ const PatternComponent: React.FC = ({ = ({ [ addSuccessToast, canUserCreateAndReadCases, - defaultNumberFormat, + formatBytes, + formatNumber, getGroupByFieldsOnClick, ilmExplain, itemIdToExpandedRowMap, @@ -171,9 +188,20 @@ const PatternComponent: React.FC = ({ pattern, patternDocsCount: patternRollup?.docsCount ?? 0, results: patternRollup?.results, + sortByColumn: sorting.sort.field, + sortByDirection: sorting.sort.direction, stats, }), - [ilmExplain, indexNames, pattern, patternRollup, stats] + [ + ilmExplain, + indexNames, + pattern, + patternRollup?.docsCount, + patternRollup?.results, + sorting.sort.direction, + sorting.sort.field, + stats, + ] ); useEffect(() => { @@ -196,6 +224,10 @@ const PatternComponent: React.FC = ({ indices: getIndexNames({ stats, ilmExplain, ilmPhases }).length, pattern, results: undefined, + sizeInBytes: getTotalSizeInBytes({ + indexNames: getIndexNames({ stats, ilmExplain, ilmPhases }), + stats, + }), stats, }); } @@ -212,18 +244,49 @@ const PatternComponent: React.FC = ({ updatePatternRollup, ]); + useEffect(() => { + if (selectedIndex?.pattern === pattern) { + const selectedPageIndex = getPageIndex({ + indexName: selectedIndex.indexName, + items, + pageSize, + }); + + if (selectedPageIndex != null) { + setPageIndex(selectedPageIndex); + } + + if (itemIdToExpandedRowMap[selectedIndex.indexName] == null) { + toggleExpanded(selectedIndex.indexName); // expand the selected index + } + + containerRef.current?.scrollIntoView(); + setSelectedIndex(null); + } + }, [ + itemIdToExpandedRowMap, + items, + pageSize, + pattern, + selectedIndex, + setSelectedIndex, + toggleExpanded, + ]); + return ( - + @@ -242,19 +305,27 @@ const PatternComponent: React.FC = ({ {loading && } {!loading && error == null && ( - +
+ +
)}
); }; -PatternComponent.displayName = 'PatternComponent'; - export const Pattern = React.memo(PatternComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/index.tsx index a8ae7a673b92e..233de9fc93a1f 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/index.tsx @@ -13,23 +13,27 @@ import { PatternLabel } from './pattern_label'; import { StatsRollup } from './stats_rollup'; interface Props { - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmExplainPhaseCounts: IlmExplainPhaseCounts; incompatible: number | undefined; indices: number | undefined; indicesChecked: number | undefined; pattern: string; patternDocsCount: number; + patternSizeInBytes: number; } const PatternSummaryComponent: React.FC = ({ - defaultNumberFormat, + formatBytes, + formatNumber, ilmExplainPhaseCounts, incompatible, indices, indicesChecked, pattern, patternDocsCount, + patternSizeInBytes, }) => ( @@ -44,12 +48,14 @@ const PatternSummaryComponent: React.FC = ({ diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/pattern_label/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/pattern_label/helpers.test.ts new file mode 100644 index 0000000000000..dfa285d60b40a --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/pattern_label/helpers.test.ts @@ -0,0 +1,97 @@ +/* + * 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 { getResultToolTip, showResult } from './helpers'; +import { ALL_PASSED, SOME_FAILED, SOME_UNCHECKED } from './translations'; + +describe('helpers', () => { + describe('getResultToolTip', () => { + test('it returns the expected tool tip when `incompatible` is undefined', () => { + expect(getResultToolTip(undefined)).toEqual(SOME_UNCHECKED); + }); + + test('it returns the expected tool tip when `incompatible` is zero', () => { + expect(getResultToolTip(0)).toEqual(ALL_PASSED); + }); + + test('it returns the expected tool tip when `incompatible` is non-zero', () => { + expect(getResultToolTip(1)).toEqual(SOME_FAILED); + }); + }); + + describe('showResult', () => { + test('it returns true when `incompatible` is defined, and `indicesChecked` equals `indices`', () => { + const incompatible = 0; // none of the indices checked had incompatible fields + const indicesChecked = 2; // all indices were checked + const indices = 2; // total indices + + expect( + showResult({ + incompatible, + indices, + indicesChecked, + }) + ).toBe(true); + }); + + test('it returns false when `incompatible` is defined, and `indices` does NOT equal `indicesChecked`', () => { + const incompatible = 0; // the one index checked (so far) didn't have any incompatible fields + const indicesChecked = 1; // only one index has been checked so far + const indices = 2; // total indices + + expect( + showResult({ + incompatible, + indices, + indicesChecked, + }) + ).toBe(false); + }); + + test('it returns false when `incompatible` is undefined', () => { + const incompatible = undefined; // a state of undefined indicates there are no results + const indicesChecked = 1; // all indices were checked + const indices = 1; // total indices + + expect( + showResult({ + incompatible, + indices, + indicesChecked, + }) + ).toBe(false); + }); + + test('it returns false when `indices` is undefined', () => { + const incompatible = 0; // none of the indices checked had incompatible fields + const indicesChecked = 2; // all indices were checked + const indices = undefined; // the total number of indices is unknown + + expect( + showResult({ + incompatible, + indices, + indicesChecked, + }) + ).toBe(false); + }); + + test('it returns false when `indicesChecked` is undefined', () => { + const incompatible = 0; // none of the indices checked had incompatible fields + const indicesChecked = undefined; // no indices were checked + const indices = 2; // total indices + + expect( + showResult({ + incompatible, + indices, + indicesChecked, + }) + ).toBe(false); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/stats_rollup/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/stats_rollup/index.tsx index dcf28487a091c..4a51880404542 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/stats_rollup/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/pattern_summary/stats_rollup/index.tsx @@ -6,7 +6,6 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useMemo } from 'react'; import styled from 'styled-components'; @@ -29,21 +28,25 @@ const DocsContainer = styled.div` const STAT_TITLE_SIZE = 's'; interface Props { - defaultNumberFormat: string; docsCount: number | undefined; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; incompatible: number | undefined; indices: number | undefined; indicesChecked: number | undefined; pattern?: string; + sizeInBytes: number | undefined; } const StatsRollupComponent: React.FC = ({ - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, incompatible, indices, indicesChecked, pattern, + sizeInBytes, }) => { const incompatibleDescription = useMemo( () => , @@ -53,11 +56,17 @@ const StatsRollupComponent: React.FC = ({ () => , [] ); + const sizeDescription = useMemo(() => , []); const docsDescription = useMemo(() => , []); const indicesDescription = useMemo(() => , []); return ( - + = ({ > @@ -88,11 +95,7 @@ const StatsRollupComponent: React.FC = ({ > @@ -110,7 +113,25 @@ const StatsRollupComponent: React.FC = ({ > + + + + + + + + @@ -126,9 +147,7 @@ const StatsRollupComponent: React.FC = ({ > diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/translations.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/translations.ts index 776ceef776172..79375b1956169 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/translations.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/pattern/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const ERROR_LOADING_METADATA_TITLE = (pattern: string) => i18n.translate('ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingMetadataTitle', { values: { pattern }, - defaultMessage: "Indices matching the {pattern} pattern won't checked", + defaultMessage: "Indices matching the {pattern} pattern won't be checked", }); export const ERROR_LOADING_METADATA_BODY = ({ diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/same_family/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/same_family/index.test.tsx index 94983d262404b..0c4a439f999bc 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/same_family/index.test.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/same_family/index.test.tsx @@ -9,7 +9,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { SAME_FAMILY } from './translations'; -import { TestProviders } from '../../mock/test_providers'; +import { TestProviders } from '../../mock/test_providers/test_providers'; import { SameFamily } from '.'; describe('SameFamily', () => { diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/stat_label/translations.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/stat_label/translations.ts index afaad5d08ed6e..1c52f29858ac4 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/stat_label/translations.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/stat_label/translations.ts @@ -69,6 +69,17 @@ export const INDICES = i18n.translate('ecsDataQualityDashboard.statLabels.indice defaultMessage: 'Indices', }); +export const SIZE = i18n.translate('ecsDataQualityDashboard.statLabels.sizeLabel', { + defaultMessage: 'Size', +}); + +export const INDICES_SIZE_PATTERN_TOOL_TIP = (pattern: string) => + i18n.translate('ecsDataQualityDashboard.statLabels.indicesSizePatternToolTip', { + values: { pattern }, + defaultMessage: + 'The total size of the primary indices matching the {pattern} pattern (does not include replicas)', + }); + export const TOTAL_COUNT_OF_INDICES_CHECKED_MATCHING_PATTERN_TOOL_TIP = (pattern: string) => i18n.translate( 'ecsDataQualityDashboard.statLabels.totalCountOfIndicesCheckedMatchingPatternToolTip', @@ -112,3 +123,10 @@ export const TOTAL_INDICES_TOOL_TIP = i18n.translate( defaultMessage: 'The total count of all indices', } ); + +export const TOTAL_SIZE_TOOL_TIP = i18n.translate( + 'ecsDataQualityDashboard.statLabels.totalSizeToolTip', + { + defaultMessage: 'The total size of all primary indices (does not include replicas)', + } +); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.test.tsx new file mode 100644 index 0000000000000..f523c96cc0801 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.test.tsx @@ -0,0 +1,154 @@ +/* + * 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 { DARK_THEME, Settings } from '@elastic/charts'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + FlattenedBucket, + getFlattenedBuckets, + getLegendItems, +} from '../body/data_quality_details/storage_details/helpers'; +import { EMPTY_STAT } from '../../helpers'; +import { alertIndexWithAllResults } from '../../mock/pattern_rollup/mock_alerts_pattern_rollup'; +import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { packetbeatNoResults } from '../../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import type { Props } from '.'; +import { StorageTreemap } from '.'; +import { DEFAULT_MAX_CHART_HEIGHT } from '../tabs/styles'; +import { NO_DATA_LABEL } from './translations'; +import { PatternRollup } from '../../types'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const ilmPhases = ['hot', 'warm', 'unmanaged']; +const patterns = ['.alerts-security.alerts-default', 'auditbeat-*', 'packetbeat-*']; + +const patternRollups: Record = { + '.alerts-security.alerts-default': alertIndexWithAllResults, + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, +}; + +const flattenedBuckets = getFlattenedBuckets({ + ilmPhases, + patternRollups, +}); + +const onIndexSelected = jest.fn(); + +const defaultProps: Props = { + flattenedBuckets, + formatBytes, + maxChartHeight: DEFAULT_MAX_CHART_HEIGHT, + onIndexSelected, + patternRollups, + patterns, + theme: DARK_THEME, +}; + +jest.mock('@elastic/charts', () => { + const actual = jest.requireActual('@elastic/charts'); + return { + ...actual, + Settings: jest.fn().mockReturnValue(null), + }; +}); + +describe('StorageTreemap', () => { + describe('when data is provided', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the treemap', () => { + expect(screen.getByTestId('storageTreemap').querySelector('.echChart')).toBeInTheDocument(); + }); + + test('it renders the legend with the expected overflow-y style', () => { + expect(screen.getByTestId('legend')).toHaveClass('eui-yScroll'); + }); + + test('it uses a theme with the expected `minFontSize` to show more labels at various screen resolutions', () => { + expect((Settings as jest.Mock).mock.calls[0][0].theme[0].partition.minFontSize).toEqual(4); + }); + + describe('legend items', () => { + const allLegendItems = getLegendItems({ patterns, flattenedBuckets, patternRollups }); + + describe('pattern legend items', () => { + const justPatterns = allLegendItems.filter((x) => x.ilmPhase == null); + + justPatterns.forEach(({ ilmPhase, index, pattern, sizeInBytes }) => { + test(`it renders the expend legend item for pattern: ilmPhase ${ilmPhase} pattern ${pattern} index ${index}`, () => { + expect( + screen.getByTestId(`chart-legend-item-${ilmPhase}${pattern}${index}`) + ).toHaveTextContent(`${pattern}${formatBytes(sizeInBytes)}`); + }); + }); + }); + + describe('index legend items', () => { + const justIndices = allLegendItems.filter((x) => x.ilmPhase != null); + + justIndices.forEach(({ ilmPhase, index, pattern, sizeInBytes }) => { + test(`it renders the expend legend item for index: ilmPhase ${ilmPhase} pattern ${pattern} index ${index}`, () => { + expect( + screen.getByTestId(`chart-legend-item-${ilmPhase}${pattern}${index}`) + ).toHaveTextContent(`${index}${formatBytes(sizeInBytes)}`); + }); + + test(`it invokes onIndexSelected() with the expected values for ilmPhase ${ilmPhase} pattern ${pattern} index ${index}`, () => { + const legendItem = screen.getByTestId( + `chart-legend-item-${ilmPhase}${pattern}${index}` + ); + + userEvent.click(legendItem); + + expect(onIndexSelected).toBeCalledWith({ indexName: index, pattern }); + }); + }); + }); + }); + }); + + describe('when the response does NOT have data', () => { + const emptyFlattenedBuckets: FlattenedBucket[] = []; + + beforeEach(() => { + render( + + + + ); + }); + + test('it does NOT render the treemap', () => { + expect(screen.queryByTestId('storageTreemap')).not.toBeInTheDocument(); + }); + + test('it does NOT render the legend', () => { + expect(screen.queryByTestId('legend')).not.toBeInTheDocument(); + }); + + test('it renders the "no data" message', () => { + expect(screen.getByText(NO_DATA_LABEL)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.tsx new file mode 100644 index 0000000000000..b42cd6072ad82 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/index.tsx @@ -0,0 +1,201 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import type { + Datum, + ElementClickListener, + FlameElementEvent, + HeatmapElementEvent, + MetricElementEvent, + PartialTheme, + PartitionElementEvent, + Theme, + WordCloudElementEvent, + XYChartElementEvent, +} from '@elastic/charts'; +import { Chart, Partition, PartitionLayout, Settings } from '@elastic/charts'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { + FlattenedBucket, + getLayersMultiDimensional, + getLegendItems, + getPathToFlattenedBucketMap, +} from '../body/data_quality_details/storage_details/helpers'; +import { ChartLegendItem } from '../../ecs_summary_donut_chart/chart_legend/chart_legend_item'; +import { NoData } from './no_data'; +import { ChartFlexItem, LegendContainer } from '../tabs/styles'; +import { PatternRollup, SelectedIndex } from '../../types'; + +export const DEFAULT_MIN_CHART_HEIGHT = 240; // px +export const LEGEND_WIDTH = 220; // px +export const LEGEND_TEXT_WITH = 120; // px + +export interface Props { + flattenedBuckets: FlattenedBucket[]; + formatBytes: (value: number | undefined) => string; + maxChartHeight?: number; + minChartHeight?: number; + onIndexSelected: ({ indexName, pattern }: SelectedIndex) => void; + patternRollups: Record; + patterns: string[]; + theme: Theme; +} + +interface GetGroupByFieldsResult { + pattern: string; + indexName: string; +} + +export const getGroupByFieldsOnClick = ( + elements: Array< + | FlameElementEvent + | HeatmapElementEvent + | MetricElementEvent + | PartitionElementEvent + | WordCloudElementEvent + | XYChartElementEvent + > +): GetGroupByFieldsResult => { + const flattened = elements.flat(2); + + const pattern = + flattened.length > 0 && 'groupByRollup' in flattened[0] && flattened[0].groupByRollup != null + ? `${flattened[0].groupByRollup}` + : ''; + + const indexName = + flattened.length > 1 && 'groupByRollup' in flattened[1] && flattened[1].groupByRollup != null + ? `${flattened[1].groupByRollup}` + : ''; + + return { + pattern, + indexName, + }; +}; + +const StorageTreemapComponent: React.FC = ({ + flattenedBuckets, + formatBytes, + maxChartHeight, + minChartHeight = DEFAULT_MIN_CHART_HEIGHT, + onIndexSelected, + patternRollups, + patterns, + theme, +}: Props) => { + const fillColor = useMemo(() => theme.background.color, [theme.background.color]); + + const treemapTheme: PartialTheme[] = useMemo( + () => [ + { + partition: { + fillLabel: { valueFont: { fontWeight: 700 } }, + idealFontSizeJump: 1.15, + maxFontSize: 16, + minFontSize: 4, + sectorLineStroke: fillColor, // draws the light or dark "lines" between partitions + sectorLineWidth: 1.5, + }, + }, + ], + [fillColor] + ); + + const onElementClick: ElementClickListener = useCallback( + (event) => { + const { indexName, pattern } = getGroupByFieldsOnClick(event); + + if (!isEmpty(indexName) && !isEmpty(pattern)) { + onIndexSelected({ indexName, pattern }); + } + }, + [onIndexSelected] + ); + + const pathToFlattenedBucketMap = getPathToFlattenedBucketMap(flattenedBuckets); + + const layers = useMemo( + () => + getLayersMultiDimensional({ + formatBytes, + layer0FillColor: fillColor, + pathToFlattenedBucketMap, + }), + [fillColor, formatBytes, pathToFlattenedBucketMap] + ); + + const valueAccessor = useCallback(({ sizeInBytes }: Datum) => sizeInBytes, []); + + const legendItems = useMemo( + () => getLegendItems({ patterns, flattenedBuckets, patternRollups }), + [flattenedBuckets, patternRollups, patterns] + ); + + if (flattenedBuckets.length === 0) { + return ; + } + + return ( + + + {flattenedBuckets.length === 0 ? ( + + ) : ( + + + formatBytes(d)} + /> + + )} + + + + + {legendItems.map(({ color, ilmPhase, index, pattern, sizeInBytes }) => ( + { + onIndexSelected({ indexName: index, pattern }); + } + : undefined + } + text={index ?? pattern} + textWidth={LEGEND_TEXT_WITH} + /> + ))} + + + + ); +}; + +export const StorageTreemap = React.memo(StorageTreemapComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.test.tsx new file mode 100644 index 0000000000000..0cf39beae7b2d --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.test.tsx @@ -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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import * as i18n from '../translations'; + +import { NoData } from '.'; + +describe('NoData', () => { + test('renders the expected "no data" message', () => { + render(); + + expect(screen.getByText(i18n.NO_DATA_LABEL)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.tsx new file mode 100644 index 0000000000000..a5edca17291d2 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/no_data/index.tsx @@ -0,0 +1,43 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; + +const NoDataLabel = styled(EuiText)` + text-align: center; +`; + +interface Props { + reason?: string; +} + +const NoDataComponent: React.FC = ({ reason }) => ( + + + + {i18n.NO_DATA_LABEL} + + + {reason != null && ( + <> + + + {reason} + + + )} + + +); + +NoDataComponent.displayName = 'NoDataComponent'; + +export const NoData = React.memo(NoDataComponent); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/translations.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/translations.ts new file mode 100644 index 0000000000000..f60cb2366cf36 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/storage_treemap/translations.ts @@ -0,0 +1,20 @@ +/* + * 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 NO_DATA_LABEL = i18n.translate('ecsDataQualityDashboard.storageTreemap.noDataLabel', { + defaultMessage: 'No data to display', +}); + +export const NO_DATA_REASON_LABEL = (stackByField1: string) => + i18n.translate('ecsDataQualityDashboard.storageTreemap.noDataReasonLabel', { + values: { + stackByField1, + }, + defaultMessage: 'The {stackByField1} field was not present in any groups', + }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.test.tsx new file mode 100644 index 0000000000000..e913d38dbb07c --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.test.tsx @@ -0,0 +1,543 @@ +/* + * 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 { EuiScreenReaderOnly, EuiTableFieldDataColumnType } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { omit } from 'lodash/fp'; +import React from 'react'; + +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { EMPTY_STAT } from '../../helpers'; +import { + getDocsCountPercent, + getResultIcon, + getResultIconColor, + getResultToolTip, + getShowPagination, + getSummaryTableColumns, + getToggleButtonId, + IndexSummaryTableItem, +} from './helpers'; +import { COLLAPSE, EXPAND, FAILED, PASSED, THIS_INDEX_HAS_NOT_BEEN_CHECKED } from './translations'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +describe('helpers', () => { + describe('getResultToolTip', () => { + test('it shows a "this index has not been checked" tool tip when `incompatible` is undefined', () => { + expect(getResultToolTip(undefined)).toEqual(THIS_INDEX_HAS_NOT_BEEN_CHECKED); + }); + + test('it returns Passed when `incompatible` is zero', () => { + expect(getResultToolTip(0)).toEqual(PASSED); + }); + + test('it returns Failed when `incompatible` is NOT zero', () => { + expect(getResultToolTip(1)).toEqual(FAILED); + }); + }); + + describe('getResultIconColor', () => { + test('it returns `ghost` when `incompatible` is undefined', () => { + expect(getResultIconColor(undefined)).toEqual('ghost'); + }); + + test('it returns `success` when `incompatible` is zero', () => { + expect(getResultIconColor(0)).toEqual('success'); + }); + + test('it returns `danger` when `incompatible` is NOT zero', () => { + expect(getResultIconColor(1)).toEqual('danger'); + }); + }); + + describe('getResultIcon', () => { + test('it returns `cross` when `incompatible` is undefined', () => { + expect(getResultIcon(undefined)).toEqual('cross'); + }); + + test('it returns `check` when `incompatible` is zero', () => { + expect(getResultIcon(0)).toEqual('check'); + }); + + test('it returns `cross` when `incompatible` is NOT zero', () => { + expect(getResultIcon(1)).toEqual('cross'); + }); + }); + + describe('getDocsCountPercent', () => { + test('it returns an empty string when `patternDocsCount` is zero', () => { + expect( + getDocsCountPercent({ + docsCount: 0, + patternDocsCount: 0, + }) + ).toEqual(''); + }); + + test('it returns the expected format when when `patternDocsCount` is non-zero, and `locales` is undefined', () => { + expect( + getDocsCountPercent({ + docsCount: 2904, + locales: undefined, + patternDocsCount: 57410, + }) + ).toEqual('5.1%'); + }); + + test('it returns the expected format when when `patternDocsCount` is non-zero, and `locales` is provided', () => { + expect( + getDocsCountPercent({ + docsCount: 2904, + locales: 'en-US', + patternDocsCount: 57410, + }) + ).toEqual('5.1%'); + }); + }); + + describe('getToggleButtonId', () => { + test('it returns the expected id when the button is expanded', () => { + expect( + getToggleButtonId({ + indexName: 'auditbeat-custom-index-1', + isExpanded: true, + pattern: 'auditbeat-*', + }) + ).toEqual('collapseauditbeat-custom-index-1auditbeat-*'); + }); + + test('it returns the expected id when the button is collapsed', () => { + expect( + getToggleButtonId({ + indexName: 'auditbeat-custom-index-1', + isExpanded: false, + pattern: 'auditbeat-*', + }) + ).toEqual('expandauditbeat-custom-index-1auditbeat-*'); + }); + }); + + describe('getSummaryTableColumns', () => { + const indexName = '.ds-auditbeat-8.6.1-2023.02.07-000001'; + + const indexSummaryTableItem: IndexSummaryTableItem = { + indexName, + docsCount: 2796, + incompatible: undefined, + ilmPhase: 'hot', + pattern: 'auditbeat-*', + patternDocsCount: 57410, + sizeInBytes: 103344068, + }; + + const hasIncompatible: IndexSummaryTableItem = { + ...indexSummaryTableItem, + incompatible: 1, // <-- one incompatible field + }; + + test('it returns the expected column configuration', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }).map((x) => omit('render', x)); + + expect(columns).toEqual([ + { + align: 'right', + isExpander: true, + name: ( + + {'Expand rows'} + + ), + width: '40px', + }, + { + field: 'incompatible', + name: 'Result', + sortable: true, + truncateText: false, + width: '50px', + }, + { field: 'indexName', name: 'Index', sortable: true, truncateText: false, width: '300px' }, + { field: 'docsCount', name: 'Docs', sortable: true, truncateText: false }, + { + field: 'incompatible', + name: 'Incompatible fields', + sortable: true, + truncateText: false, + }, + { field: 'ilmPhase', name: 'ILM Phase', sortable: true, truncateText: false }, + { field: 'sizeInBytes', name: 'Size', sortable: true, truncateText: false }, + ]); + }); + + describe('expand rows render()', () => { + test('it renders an Expand button when the row is NOT expanded', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const expandRowsRender = (columns[0] as EuiTableFieldDataColumnType) + .render; + + render( + + {expandRowsRender != null && + expandRowsRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + expect(screen.getByLabelText(EXPAND)).toBeInTheDocument(); + }); + + test('it renders a Collapse button when the row is expanded', () => { + const itemIdToExpandedRowMap: Record = { + [indexName]: () => null, + }; + + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const expandRowsRender = (columns[0] as EuiTableFieldDataColumnType) + .render; + + render( + + {expandRowsRender != null && + expandRowsRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + expect(screen.getByLabelText(COLLAPSE)).toBeInTheDocument(); + }); + + test('it invokes the `toggleExpanded` with the index name when the button is clicked', () => { + const toggleExpanded = jest.fn(); + + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded, + }); + const expandRowsRender = (columns[0] as EuiTableFieldDataColumnType) + .render; + + render( + + {expandRowsRender != null && + expandRowsRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + const button = screen.getByLabelText(EXPAND); + userEvent.click(button); + + expect(toggleExpanded).toBeCalledWith(indexName); + }); + }); + + describe('incompatible render()', () => { + test('it renders a placeholder when incompatible is undefined', () => { + const incompatibleIsUndefined: IndexSummaryTableItem = { + ...indexSummaryTableItem, + incompatible: undefined, // <-- + }; + + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const incompatibleRender = ( + columns[1] as EuiTableFieldDataColumnType + ).render; + + render( + + {incompatibleRender != null && + incompatibleRender(incompatibleIsUndefined, incompatibleIsUndefined)} + + ); + + expect(screen.getByTestId('incompatiblePlaceholder')).toHaveTextContent(EMPTY_STAT); + }); + + test('it renders the expected icon when there are incompatible fields', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const incompatibleRender = ( + columns[1] as EuiTableFieldDataColumnType + ).render; + + render( + + {incompatibleRender != null && incompatibleRender(hasIncompatible, hasIncompatible)} + + ); + + expect(screen.getByTestId('resultIcon')).toHaveAttribute('data-euiicon-type', 'cross'); + }); + + test('it renders the expected icon when there are zero fields', () => { + const zeroIncompatible: IndexSummaryTableItem = { + ...indexSummaryTableItem, + incompatible: 0, // <-- one incompatible field + }; + + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const incompatibleRender = ( + columns[1] as EuiTableFieldDataColumnType + ).render; + + render( + + {incompatibleRender != null && incompatibleRender(zeroIncompatible, zeroIncompatible)} + + ); + + expect(screen.getByTestId('resultIcon')).toHaveAttribute('data-euiicon-type', 'check'); + }); + }); + + describe('indexName render()', () => { + test('it renders the index name', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const indexNameRender = (columns[2] as EuiTableFieldDataColumnType) + .render; + + render( + + {indexNameRender != null && + indexNameRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + expect(screen.getByTestId('indexName')).toHaveTextContent(indexName); + }); + }); + + describe('docsCount render()', () => { + beforeEach(() => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const docsCountRender = (columns[3] as EuiTableFieldDataColumnType) + .render; + + render( + + {docsCountRender != null && docsCountRender(hasIncompatible, hasIncompatible)} + + ); + }); + + test('it renders the expected value', () => { + expect(screen.getByTestId('docsCount')).toHaveAttribute( + 'value', + String(hasIncompatible.docsCount) + ); + }); + + test('it renders the expected max (progress)', () => { + expect(screen.getByTestId('docsCount')).toHaveAttribute( + 'max', + String(hasIncompatible.patternDocsCount) + ); + }); + }); + + describe('incompatible column render()', () => { + test('it renders the expected value', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const incompatibleRender = ( + columns[4] as EuiTableFieldDataColumnType + ).render; + + render( + + {incompatibleRender != null && incompatibleRender(hasIncompatible, hasIncompatible)} + + ); + + expect(screen.getByTestId('incompatibleStat')).toHaveTextContent('1'); + }); + + test('it renders the expected placeholder when incompatible is undefined', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const incompatibleRender = ( + columns[4] as EuiTableFieldDataColumnType + ).render; + + render( + + {incompatibleRender != null && + incompatibleRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + expect(screen.getByTestId('incompatibleStat')).toHaveTextContent('-- --'); // the euiScreenReaderOnly content renders an additional set of -- + }); + }); + + describe('ilmPhase column render()', () => { + test('it renders the expected ilmPhase badge content', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const ilmPhaseRender = (columns[5] as EuiTableFieldDataColumnType) + .render; + + render( + + {ilmPhaseRender != null && ilmPhaseRender(hasIncompatible, hasIncompatible)} + + ); + + expect(screen.getByTestId('ilmPhase')).toHaveTextContent('hot'); + }); + + test('it does NOT render the ilmPhase badge when `ilmPhase` is undefined', () => { + const ilmPhaseIsUndefined: IndexSummaryTableItem = { + ...indexSummaryTableItem, + ilmPhase: undefined, // <-- + }; + + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + const ilmPhaseRender = (columns[5] as EuiTableFieldDataColumnType) + .render; + + render( + + {ilmPhaseRender != null && ilmPhaseRender(ilmPhaseIsUndefined, ilmPhaseIsUndefined)} + + ); + + expect(screen.queryByTestId('ilmPhase')).not.toBeInTheDocument(); + }); + }); + + describe('sizeInBytes render()', () => { + test('it renders the expected formatted bytes', () => { + const columns = getSummaryTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap: {}, + pattern: 'auditbeat-*', + toggleExpanded: jest.fn(), + }); + + const sizeInBytesRender = (columns[6] as EuiTableFieldDataColumnType) + .render; + + render( + + {sizeInBytesRender != null && + sizeInBytesRender(indexSummaryTableItem, indexSummaryTableItem)} + + ); + + expect(screen.getByTestId('sizeInBytes')).toHaveTextContent('98.6MB'); + }); + }); + }); + + describe('getShowPagination', () => { + test('it returns true when `totalItemCount` is greater than `minPageSize`', () => { + expect( + getShowPagination({ + minPageSize: 10, + totalItemCount: 11, + }) + ).toBe(true); + }); + + test('it returns false when `totalItemCount` equals `minPageSize`', () => { + expect( + getShowPagination({ + minPageSize: 10, + totalItemCount: 10, + }) + ).toBe(false); + }); + + test('it returns false when `totalItemCount` is less than `minPageSize`', () => { + expect( + getShowPagination({ + minPageSize: 10, + totalItemCount: 9, + }) + ).toBe(false); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.tsx index e3789fce0bb5b..89e0d78fddb6b 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/helpers.tsx @@ -21,6 +21,7 @@ import styled from 'styled-components'; import { EMPTY_STAT, getIlmPhaseDescription, getIncompatibleStatColor } from '../../helpers'; import { INCOMPATIBLE_INDEX_TOOL_TIP } from '../stat_label/translations'; +import { INDEX_SIZE_TOOLTIP } from '../../translations'; import * as i18n from './translations'; import type { IlmPhase } from '../../types'; @@ -39,6 +40,7 @@ export interface IndexSummaryTableItem { ilmPhase: IlmPhase | undefined; pattern: string; patternDocsCount: number; + sizeInBytes: number; } export const getResultToolTip = (incompatible: number | undefined): string => { @@ -83,13 +85,27 @@ export const getDocsCountPercent = ({ }) : ''; +export const getToggleButtonId = ({ + indexName, + isExpanded, + pattern, +}: { + indexName: string; + isExpanded: boolean; + pattern: string; +}): string => (isExpanded ? `collapse${indexName}${pattern}` : `expand${indexName}${pattern}`); + export const getSummaryTableColumns = ({ + formatBytes, formatNumber, itemIdToExpandedRowMap, + pattern, toggleExpanded, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; itemIdToExpandedRowMap: Record; + pattern: string; toggleExpanded: (indexName: string) => void; }): Array> => [ { @@ -103,6 +119,11 @@ export const getSummaryTableColumns = ({ render: ({ indexName }: IndexSummaryTableItem) => ( toggleExpanded(indexName)} iconType={itemIdToExpandedRowMap[indexName] ? 'arrowDown' : 'arrowRight'} /> @@ -115,11 +136,15 @@ export const getSummaryTableColumns = ({ render: (_, { incompatible }) => incompatible != null ? ( - + ) : ( - {EMPTY_STAT} + {EMPTY_STAT} ), sortable: true, @@ -129,9 +154,11 @@ export const getSummaryTableColumns = ({ { field: 'indexName', name: i18n.INDEX, - render: (_, { indexName, pattern }) => ( + render: (_, { indexName }) => ( - {indexName} + + {indexName} + ), sortable: true, @@ -144,6 +171,7 @@ export const getSummaryTableColumns = ({ render: (_, { docsCount, patternDocsCount }) => ( ( ), - sortable: false, + sortable: true, truncateText: false, }, { @@ -178,10 +207,23 @@ export const getSummaryTableColumns = ({ render: (_, { ilmPhase }) => ilmPhase != null ? ( - {ilmPhase} + + {ilmPhase} + ) : null, - sortable: false, + sortable: true, + truncateText: false, + }, + { + field: 'sizeInBytes', + name: i18n.SIZE, + render: (_, { sizeInBytes }) => ( + + {formatBytes(sizeInBytes)} + + ), + sortable: true, truncateText: false, }, ]; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.test.tsx new file mode 100644 index 0000000000000..235ec61a204af --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.test.tsx @@ -0,0 +1,89 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { EMPTY_STAT } from '../../helpers'; +import { getSummaryTableColumns } from './helpers'; +import { mockIlmExplain } from '../../mock/ilm_explain/mock_ilm_explain'; +import { auditbeatWithAllResults } from '../../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { mockStats } from '../../mock/stats/mock_stats'; +import { TestProviders } from '../../mock/test_providers/test_providers'; +import { getSummaryTableItems } from '../pattern/helpers'; +import { SortConfig } from '../../types'; +import { Props, SummaryTable } from '.'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const indexNames = [ + '.ds-auditbeat-8.6.1-2023.02.07-000001', + 'auditbeat-custom-empty-index-1', + 'auditbeat-custom-index-1', + '.internal.alerts-security.alerts-default-000001', + '.ds-packetbeat-8.5.3-2023.02.04-000001', + '.ds-packetbeat-8.6.1-2023.02.04-000001', +]; + +export const defaultSort: SortConfig = { + sort: { + direction: 'desc', + field: 'docsCount', + }, +}; + +const pattern = 'auditbeat-*'; + +const items = getSummaryTableItems({ + ilmExplain: mockIlmExplain, + indexNames: indexNames ?? [], + pattern, + patternDocsCount: auditbeatWithAllResults?.docsCount ?? 0, + results: auditbeatWithAllResults?.results, + sortByColumn: defaultSort.sort.field, + sortByDirection: defaultSort.sort.direction, + stats: mockStats, +}); + +const defaultProps: Props = { + formatBytes, + formatNumber, + getTableColumns: getSummaryTableColumns, + itemIdToExpandedRowMap: {}, + items, + pageIndex: 0, + pageSize: 10, + pattern, + setPageIndex: jest.fn(), + setPageSize: jest.fn(), + setSorting: jest.fn(), + sorting: defaultSort, + toggleExpanded: jest.fn(), +}; + +describe('SummaryTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + + render( + + + + ); + }); + + test('it renders the summary table', () => { + expect(screen.getByTestId('summaryTable')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.tsx index 7b02add151803..2dd2c4e214dc0 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/summary_table/index.tsx @@ -5,79 +5,79 @@ * 2.0. */ -import type { - CriteriaWithPagination, - Direction, - EuiBasicTableColumn, - Pagination, -} from '@elastic/eui'; +import type { CriteriaWithPagination, EuiBasicTableColumn, Pagination } from '@elastic/eui'; import { EuiInMemoryTable } from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; -import { EMPTY_STAT } from '../../helpers'; import type { IndexSummaryTableItem } from './helpers'; import { getShowPagination } from './helpers'; +import { defaultSort, MIN_PAGE_SIZE } from '../pattern/helpers'; +import { SortConfig } from '../../types'; -const MIN_PAGE_SIZE = 10; - -interface SortConfig { - sort: { - direction: Direction; - field: string; - }; -} - -const defaultSort: SortConfig = { - sort: { - direction: 'desc', - field: 'docsCount', - }, -}; - -interface Props { - defaultNumberFormat: string; +export interface Props { + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; getTableColumns: ({ + formatBytes, formatNumber, itemIdToExpandedRowMap, + pattern, toggleExpanded, }: { + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; itemIdToExpandedRowMap: Record; + pattern: string; toggleExpanded: (indexName: string) => void; }) => Array>; itemIdToExpandedRowMap: Record; items: IndexSummaryTableItem[]; + pageIndex: number; + pageSize: number; + pattern: string; + setPageIndex: (pageIndex: number) => void; + setPageSize: (pageSize: number) => void; + setSorting: (sortConfig: SortConfig) => void; + sorting: SortConfig; toggleExpanded: (indexName: string) => void; } const SummaryTableComponent: React.FC = ({ - defaultNumberFormat, + formatBytes, + formatNumber, getTableColumns, itemIdToExpandedRowMap, items, + pageIndex, + pageSize, + pattern, + setPageIndex, + setPageSize, + setSorting, + sorting, toggleExpanded, }) => { - const [sorting, setSorting] = useState(defaultSort); - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(MIN_PAGE_SIZE); const columns = useMemo( - () => getTableColumns({ formatNumber, itemIdToExpandedRowMap, toggleExpanded }), - [formatNumber, getTableColumns, itemIdToExpandedRowMap, toggleExpanded] + () => + getTableColumns({ + formatBytes, + formatNumber, + itemIdToExpandedRowMap, + pattern, + toggleExpanded, + }), + [formatBytes, formatNumber, getTableColumns, itemIdToExpandedRowMap, pattern, toggleExpanded] ); const getItemId = useCallback((item: IndexSummaryTableItem) => item.indexName, []); - const onChange = useCallback(({ page, sort }: CriteriaWithPagination) => { - setSorting({ sort: sort ?? defaultSort.sort }); - - setPageIndex(page.index); - setPageSize(page.size); - }, []); + const onChange = useCallback( + ({ page, sort }: CriteriaWithPagination) => { + setSorting({ sort: sort ?? defaultSort.sort }); + setPageIndex(page.index); + setPageSize(page.size); + }, + [setPageIndex, setPageSize, setSorting] + ); const pagination: Pagination = useMemo( () => ({ @@ -91,9 +91,10 @@ const SummaryTableComponent: React.FC = ({ return ( + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; describe('helpers', () => { describe('getCustomMarkdownComment', () => { @@ -23,4 +44,52 @@ ${ECS_IS_A_PERMISSIVE_SCHEMA} `); }); }); + + describe('showCustomCallout', () => { + test('it returns false when `enrichedFieldMetadata` is empty', () => { + expect(showCustomCallout([])).toBe(false); + }); + + test('it returns true when `enrichedFieldMetadata` is NOT empty', () => { + expect(showCustomCallout([someField])).toBe(true); + }); + }); + + describe('getCustomColor', () => { + test('it returns the expected color when there are custom fields', () => { + expect(getCustomColor(mockPartitionedFieldMetadata)).toEqual(euiThemeVars.euiColorLightShade); + }); + + test('it returns the expected color when custom fields is empty', () => { + const noCustomFields: PartitionedFieldMetadata = { + ...mockPartitionedFieldMetadata, + custom: [], // <-- empty + }; + + expect(getCustomColor(noCustomFields)).toEqual(euiThemeVars.euiTextColor); + }); + }); + + describe('getAllCustomMarkdownComments', () => { + test('it returns the expected comment', () => { + expect( + getAllCustomMarkdownComments({ + docsCount: 4, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual([ + '### auditbeat-custom-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` | 27.7KB |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + `#### 4 Custom field mappings\n\nThese fields are not defined by the Elastic Common Schema (ECS), version ${EcsVersion}.\n\nECS is a permissive schema. If your events have additional data that cannot be mapped to ECS, you can simply add them to your events, using custom field names.\n`, + '#### Custom fields - auditbeat-custom-index-1\n\n\n| Field | Index mapping type | \n|-------|--------------------|\n| host.name.keyword | `keyword` | `--` |\n| some.field | `text` | `--` |\n| some.field.keyword | `keyword` | `--` |\n| source.ip.keyword | `keyword` | `--` |\n', + ]); + }); + }); }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/helpers.ts index 1d8de8ac57593..7701db46d6c98 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/helpers.ts @@ -10,14 +10,11 @@ import { euiThemeVars } from '@kbn/ui-theme'; import { FIELD, INDEX_MAPPING_TYPE } from '../../../compare_fields_table/translations'; import { - ECS_FIELD_REFERENCE_URL, - ECS_REFERENCE_URL, getSummaryMarkdownComment, getCustomMarkdownTableRows, getMarkdownComment, getMarkdownTable, getTabCountsMarkdownComment, - MAPPING_URL, getSummaryTableMarkdownComment, } from '../../index_properties/markdown/helpers'; import * as i18n from '../../index_properties/translations'; @@ -50,33 +47,33 @@ export const getCustomColor = (partitionedFieldMetadata: PartitionedFieldMetadat export const getAllCustomMarkdownComments = ({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata; patternDocsCount: number; + sizeInBytes: number | undefined; }): string[] => [ - getSummaryMarkdownComment({ - ecsFieldReferenceUrl: ECS_FIELD_REFERENCE_URL, - ecsReferenceUrl: ECS_REFERENCE_URL, - incompatible: partitionedFieldMetadata.incompatible.length, - indexName, - mappingUrl: MAPPING_URL, - }), + getSummaryMarkdownComment(indexName), getSummaryTableMarkdownComment({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }), getTabCountsMarkdownComment(partitionedFieldMetadata), getCustomMarkdownComment({ diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/index.tsx index bfffbd1393ef6..09f7136d7108a 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/custom_tab/index.tsx @@ -13,13 +13,11 @@ import { EuiEmptyPrompt, EuiSpacer, } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useCallback, useMemo } from 'react'; import { CustomCallout } from '../callouts/custom_callout'; import { CompareFieldsTable } from '../../../compare_fields_table'; import { getCustomTableColumns } from '../../../compare_fields_table/helpers'; -import { EMPTY_STAT } from '../../../helpers'; import { EmptyPromptBody } from '../../index_properties/empty_prompt_body'; import { EmptyPromptTitle } from '../../index_properties/empty_prompt_title'; import { getAllCustomMarkdownComments, showCustomCallout } from './helpers'; @@ -29,39 +27,49 @@ import type { IlmPhase, PartitionedFieldMetadata } from '../../../types'; interface Props { addSuccessToast: (toast: { title: string }) => void; - defaultNumberFormat: string; docsCount: number; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata; patternDocsCount: number; + sizeInBytes: number | undefined; } const CustomTabComponent: React.FC = ({ addSuccessToast, - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }) => { - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); const markdownComments: string[] = useMemo( () => getAllCustomMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }), - [docsCount, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount] + [ + docsCount, + formatBytes, + formatNumber, + ilmPhase, + indexName, + partitionedFieldMetadata, + patternDocsCount, + sizeInBytes, + ] ); const body = useMemo(() => , []); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx new file mode 100644 index 0000000000000..c0dc6a8aaafe2 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 { DARK_THEME } from '@elastic/charts'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { omit } from 'lodash/fp'; + +import { + eventCategory, + someField, + timestamp, +} from '../../mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { mockPartitionedFieldMetadata } from '../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { mockStatsGreenIndex } from '../../mock/stats/mock_stats_green_index'; +import { + getEcsCompliantColor, + getMissingTimestampComment, + getTabs, + showMissingTimestampCallout, +} from './helpers'; + +describe('helpers', () => { + describe('getMissingTimestampComment', () => { + test('it returns the expected comment', () => { + expect(getMissingTimestampComment()).toEqual( + '#### Missing an @timestamp (date) field mapping for this index\n\nConsider adding an @timestamp (date) field mapping to this index, as required by the Elastic Common Schema (ECS), because:\n\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n' + ); + }); + }); + + describe('showMissingTimestampCallout', () => { + test('it returns true when `enrichedFieldMetadata` is empty', () => { + expect(showMissingTimestampCallout([])).toBe(true); + }); + + test('it returns false when `enrichedFieldMetadata` contains an @timestamp field', () => { + expect(showMissingTimestampCallout([timestamp, eventCategory, someField])).toBe(false); + }); + + test('it returns true when `enrichedFieldMetadata` does NOT contain an @timestamp field', () => { + expect(showMissingTimestampCallout([eventCategory, someField])).toBe(true); + }); + }); + + describe('getEcsCompliantColor', () => { + test('it returns the expected color for the ECS compliant data when the data includes an @timestamp', () => { + expect(getEcsCompliantColor(mockPartitionedFieldMetadata)).toEqual( + euiThemeVars.euiColorSuccess + ); + }); + + test('it returns the expected color for the ECS compliant data does NOT includes an @timestamp', () => { + const noTimestamp = { + ...mockPartitionedFieldMetadata, + ecsCompliant: mockPartitionedFieldMetadata.ecsCompliant.filter( + ({ name }) => name !== '@timestamp' + ), + }; + + expect(getEcsCompliantColor(noTimestamp)).toEqual(euiThemeVars.euiColorDanger); + }); + }); + + describe('getTabs', () => { + test('it returns the expected tabs', () => { + expect( + getTabs({ + addSuccessToast: jest.fn(), + addToNewCaseDisabled: false, + docsCount: 4, + formatBytes: jest.fn(), + formatNumber: jest.fn(), + getGroupByFieldsOnClick: jest.fn(), + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + onAddToNewCase: jest.fn(), + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'auditbeat-*', + patternDocsCount: 57410, + setSelectedTabId: jest.fn(), + stats: mockStatsGreenIndex, + theme: DARK_THEME, + }).map((x) => omit(['append', 'content'], x)) + ).toEqual([ + { + id: 'summaryTab', + name: 'Summary', + }, + { + id: 'incompatibleTab', + name: 'Incompatible fields', + }, + { + id: 'customTab', + name: 'Custom fields', + }, + { + id: 'ecsCompliantTab', + name: 'ECS compliant fields', + }, + { + id: 'allTab', + name: 'All fields', + }, + ]); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx index a98ad92ba6cd9..c0cbebd45cb8d 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/helpers.tsx @@ -14,6 +14,7 @@ import type { WordCloudElementEvent, XYChartElementEvent, } from '@elastic/charts'; +import type { IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types'; import { EuiBadge } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; import React from 'react'; @@ -35,6 +36,7 @@ import { getFillColor } from './summary_tab/helpers'; import * as i18n from '../index_properties/translations'; import { SummaryTab } from './summary_tab'; import type { EnrichedFieldMetadata, IlmPhase, PartitionedFieldMetadata } from '../../types'; +import { getSizeInBytes } from '../../helpers'; export const getMissingTimestampComment = (): string => getMarkdownComment({ @@ -48,7 +50,7 @@ ${i18n.PAGES_MAY_NOT_DISPLAY_EVENTS} export const showMissingTimestampCallout = ( enrichedFieldMetadata: EnrichedFieldMetadata[] -): boolean => enrichedFieldMetadata.length === 0; +): boolean => !enrichedFieldMetadata.some((x) => x.name === '@timestamp'); export const getEcsCompliantColor = (partitionedFieldMetadata: PartitionedFieldMetadata): string => showMissingTimestampCallout(partitionedFieldMetadata.ecsCompliant) @@ -58,8 +60,9 @@ export const getEcsCompliantColor = (partitionedFieldMetadata: PartitionedFieldM export const getTabs = ({ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, getGroupByFieldsOnClick, ilmPhase, indexName, @@ -68,11 +71,13 @@ export const getTabs = ({ pattern, patternDocsCount, setSelectedTabId, + stats, theme, }: { addSuccessToast: (toast: { title: string }) => void; addToNewCaseDisabled: boolean; - defaultNumberFormat: string; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; docsCount: number; getGroupByFieldsOnClick: ( elements: Array< @@ -94,6 +99,7 @@ export const getTabs = ({ pattern: string; patternDocsCount: number; setSelectedTabId: (tabId: string) => void; + stats: Record | null; theme: Theme; }) => [ { @@ -101,7 +107,8 @@ export const getTabs = ({ ), @@ -127,13 +135,15 @@ export const getTabs = ({ ), id: INCOMPATIBLE_TAB_ID, @@ -148,12 +158,14 @@ export const getTabs = ({ content: ( ), id: 'customTab', diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.test.ts index e82bfd4d2efdc..54babce560f25 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.test.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.test.ts @@ -5,11 +5,20 @@ * 2.0. */ -import { EcsVersion } from '@kbn/ecs'; import numeral from '@elastic/numeral'; +import { EcsVersion } from '@kbn/ecs'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { + getAllIncompatibleMarkdownComments, + getIncompatibleColor, + getIncompatibleFieldsMarkdownComment, + getIncompatibleFieldsMarkdownTablesComment, + getIncompatibleMappings, + getIncompatibleValues, + showInvalidCallout, +} from './helpers'; import { EMPTY_STAT } from '../../../helpers'; -import { mockPartitionedFieldMetadata } from '../../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; import { DETECTION_ENGINE_RULES_MAY_NOT_MATCH, INCOMPATIBLE_FIELDS_WITH, @@ -17,10 +26,8 @@ import { PAGES_MAY_NOT_DISPLAY_EVENTS, WHEN_AN_INCOMPATIBLE_FIELD, } from '../../index_properties/translations'; -import { - getIncompatibleFieldsMarkdownComment, - getAllIncompatibleMarkdownComments, -} from './helpers'; +import { mockPartitionedFieldMetadata } from '../../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { PartitionedFieldMetadata } from '../../../types'; describe('helpers', () => { describe('getIncompatibleFieldsMarkdownComment', () => { @@ -44,27 +51,313 @@ ${MAPPINGS_THAT_CONFLICT_WITH_ECS} }); }); + describe('showInvalidCallout', () => { + test('it returns false when the `enrichedFieldMetadata` is empty', () => { + expect(showInvalidCallout([])).toBe(false); + }); + + test('it returns true when the `enrichedFieldMetadata` is NOT empty', () => { + expect(showInvalidCallout(mockPartitionedFieldMetadata.incompatible)).toBe(true); + }); + }); + + describe('getIncompatibleColor', () => { + test('it returns the expected color', () => { + expect(getIncompatibleColor()).toEqual(euiThemeVars.euiColorDanger); + }); + }); + + describe('getIncompatibleMappings', () => { + test('it (only) returns the mappings where type !== indexFieldType', () => { + expect(getIncompatibleMappings(mockPartitionedFieldMetadata.incompatible)).toEqual([ + { + dashed_name: 'host-name', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + flat_name: 'host.name', + hasEcsMetadata: true, + ignore_above: 1024, + indexFieldName: 'host.name', + indexFieldType: 'text', + indexInvalidValues: [], + isEcsCompliant: false, + isInSameFamily: false, + level: 'core', + name: 'name', + normalize: [], + short: 'Name of the host.', + type: 'keyword', + }, + { + dashed_name: 'source-ip', + description: 'IP address of the source (IPv4 or IPv6).', + flat_name: 'source.ip', + hasEcsMetadata: true, + indexFieldName: 'source.ip', + indexFieldType: 'text', + indexInvalidValues: [], + isEcsCompliant: false, + isInSameFamily: false, + level: 'core', + name: 'ip', + normalize: [], + short: 'IP address of the source.', + type: 'ip', + }, + ]); + }); + + test('it filters-out ECS complaint fields', () => { + expect(getIncompatibleMappings(mockPartitionedFieldMetadata.ecsCompliant)).toEqual([]); + }); + }); + + describe('getIncompatibleValues', () => { + test('it (only) returns the mappings with indexInvalidValues', () => { + expect(getIncompatibleValues(mockPartitionedFieldMetadata.incompatible)).toEqual([ + { + allowed_values: [ + { + description: + 'Events in this category are related to the challenge and response process in which credentials are supplied and verified to allow the creation of a session. Common sources for these logs are Windows event logs and ssh logs. Visualize and analyze events in this category to look for failed logins, and other authentication-related activity.', + expected_event_types: ['start', 'end', 'info'], + name: 'authentication', + }, + { + description: + 'Events in the configuration category have to deal with creating, modifying, or deleting the settings or parameters of an application, process, or system.\nExample sources include security policy change logs, configuration auditing logging, and system integrity monitoring.', + expected_event_types: ['access', 'change', 'creation', 'deletion', 'info'], + name: 'configuration', + }, + { + description: + 'The database category denotes events and metrics relating to a data storage and retrieval system. Note that use of this category is not limited to relational database systems. Examples include event logs from MS SQL, MySQL, Elasticsearch, MongoDB, etc. Use this category to visualize and analyze database activity such as accesses and changes.', + expected_event_types: ['access', 'change', 'info', 'error'], + name: 'database', + }, + { + description: + 'Events in the driver category have to do with operating system device drivers and similar software entities such as Windows drivers, kernel extensions, kernel modules, etc.\nUse events and metrics in this category to visualize and analyze driver-related activity and status on hosts.', + expected_event_types: ['change', 'end', 'info', 'start'], + name: 'driver', + }, + { + description: + 'This category is used for events relating to email messages, email attachments, and email network or protocol activity.\nEmails events can be produced by email security gateways, mail transfer agents, email cloud service providers, or mail server monitoring applications.', + expected_event_types: ['info'], + name: 'email', + }, + { + description: + 'Relating to a set of information that has been created on, or has existed on a filesystem. Use this category of events to visualize and analyze the creation, access, and deletions of files. Events in this category can come from both host-based and network-based sources. An example source of a network-based detection of a file transfer would be the Zeek file.log.', + expected_event_types: ['change', 'creation', 'deletion', 'info'], + name: 'file', + }, + { + description: + 'Use this category to visualize and analyze information such as host inventory or host lifecycle events.\nMost of the events in this category can usually be observed from the outside, such as from a hypervisor or a control plane\'s point of view. Some can also be seen from within, such as "start" or "end".\nNote that this category is for information about hosts themselves; it is not meant to capture activity "happening on a host".', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'host', + }, + { + description: + 'Identity and access management (IAM) events relating to users, groups, and administration. Use this category to visualize and analyze IAM-related logs and data from active directory, LDAP, Okta, Duo, and other IAM systems.', + expected_event_types: [ + 'admin', + 'change', + 'creation', + 'deletion', + 'group', + 'info', + 'user', + ], + name: 'iam', + }, + { + description: + 'Relating to intrusion detections from IDS/IPS systems and functions, both network and host-based. Use this category to visualize and analyze intrusion detection alerts from systems such as Snort, Suricata, and Palo Alto threat detections.', + expected_event_types: ['allowed', 'denied', 'info'], + name: 'intrusion_detection', + }, + { + description: + 'Malware detection events and alerts. Use this category to visualize and analyze malware detections from EDR/EPP systems such as Elastic Endpoint Security, Symantec Endpoint Protection, Crowdstrike, and network IDS/IPS systems such as Suricata, or other sources of malware-related events such as Palo Alto Networks threat logs and Wildfire logs.', + expected_event_types: ['info'], + name: 'malware', + }, + { + description: + 'Relating to all network activity, including network connection lifecycle, network traffic, and essentially any event that includes an IP address. Many events containing decoded network protocol transactions fit into this category. Use events in this category to visualize or analyze counts of network ports, protocols, addresses, geolocation information, etc.', + expected_event_types: [ + 'access', + 'allowed', + 'connection', + 'denied', + 'end', + 'info', + 'protocol', + 'start', + ], + name: 'network', + }, + { + description: + 'Relating to software packages installed on hosts. Use this category to visualize and analyze inventory of software installed on various hosts, or to determine host vulnerability in the absence of vulnerability scan data.', + expected_event_types: [ + 'access', + 'change', + 'deletion', + 'info', + 'installation', + 'start', + ], + name: 'package', + }, + { + description: + 'Use this category of events to visualize and analyze process-specific information such as lifecycle events or process ancestry.', + expected_event_types: ['access', 'change', 'end', 'info', 'start'], + name: 'process', + }, + { + description: + 'Having to do with settings and assets stored in the Windows registry. Use this category to visualize and analyze activity such as registry access and modifications.', + expected_event_types: ['access', 'change', 'creation', 'deletion'], + name: 'registry', + }, + { + description: + 'The session category is applied to events and metrics regarding logical persistent connections to hosts and services. Use this category to visualize and analyze interactive or automated persistent connections between assets. Data for this category may come from Windows Event logs, SSH logs, or stateless sessions such as HTTP cookie-based sessions, etc.', + expected_event_types: ['start', 'end', 'info'], + name: 'session', + }, + { + description: + "Use this category to visualize and analyze events describing threat actors' targets, motives, or behaviors.", + expected_event_types: ['indicator'], + name: 'threat', + }, + { + description: + 'Relating to vulnerability scan results. Use this category to analyze vulnerabilities detected by Tenable, Qualys, internal scanners, and other vulnerability management sources.', + expected_event_types: ['info'], + name: 'vulnerability', + }, + { + description: + 'Relating to web server access. Use this category to create a dashboard of web server/proxy activity from apache, IIS, nginx web servers, etc. Note: events from network observers such as Zeek http log may also be included in this category.', + expected_event_types: ['access', 'error', 'info'], + name: 'web', + }, + ], + dashed_name: 'event-category', + description: + 'This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy.\n`event.category` represents the "big buckets" of ECS categories. For example, filtering on `event.category:process` yields all events relating to process activity. This field is closely related to `event.type`, which is used as a subcategory.\nThis field is an array. This will allow proper categorization of some events that fall in multiple categories.', + example: 'authentication', + flat_name: 'event.category', + ignore_above: 1024, + level: 'core', + name: 'category', + normalize: ['array'], + short: 'Event category. The second categorization field in the hierarchy.', + type: 'keyword', + indexFieldName: 'event.category', + indexFieldType: 'keyword', + indexInvalidValues: [ + { count: 2, fieldName: 'an_invalid_category' }, + { count: 1, fieldName: 'theory' }, + ], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: true, + }, + ]); + }); + + test('it filters-out ECS complaint fields', () => { + expect(getIncompatibleValues(mockPartitionedFieldMetadata.ecsCompliant)).toEqual([]); + }); + }); + + describe('getIncompatibleFieldsMarkdownTablesComment', () => { + test('it returns the expected comment when the index has `incompatibleMappings` and `incompatibleValues`', () => { + expect( + getIncompatibleFieldsMarkdownTablesComment({ + incompatibleMappings: [ + mockPartitionedFieldMetadata.incompatible[1], + mockPartitionedFieldMetadata.incompatible[2], + ], + incompatibleValues: [mockPartitionedFieldMetadata.incompatible[0]], + indexName: 'auditbeat-custom-index-1', + }) + ).toEqual( + '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n' + ); + }); + + test('it returns the expected comment when the index does NOT have `incompatibleMappings` and `incompatibleValues`', () => { + expect( + getIncompatibleFieldsMarkdownTablesComment({ + incompatibleMappings: [], // <-- no `incompatibleMappings` + incompatibleValues: [], // <-- no `incompatibleValues` + indexName: 'auditbeat-custom-index-1', + }) + ).toEqual('\n\n\n'); + }); + }); + describe('getAllIncompatibleMarkdownComments', () => { - test('it returns the expected collection of comments', () => { - const defaultNumberFormat = '0,0.[000]'; - const formatNumber = (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + const defaultBytesFormat = '0,0.[0]b'; + const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + + const defaultNumberFormat = '0,0.[000]'; + const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + test('it returns the expected collection of comments', () => { expect( getAllIncompatibleMarkdownComments({ docsCount: 4, + formatBytes, formatNumber, ilmPhase: 'unmanaged', indexName: 'auditbeat-custom-index-1', partitionedFieldMetadata: mockPartitionedFieldMetadata, patternDocsCount: 57410, + sizeInBytes: 28413, }) ).toEqual([ '### auditbeat-custom-index-1\n', - '| Result | Index | Docs | Incompatible fields | ILM Phase |\n|--------|-------|------|---------------------|-----------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` |\n\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` | 27.7KB |\n\n', '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', `#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version ${EcsVersion}.\n\n${INCOMPATIBLE_FIELDS_WITH}\n\n${WHEN_AN_INCOMPATIBLE_FIELD}\n${DETECTION_ENGINE_RULES_MAY_NOT_MATCH}\n${PAGES_MAY_NOT_DISPLAY_EVENTS}\n${MAPPINGS_THAT_CONFLICT_WITH_ECS}\n`, - '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2),\n`theory` (1) |\n\n', + '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ]); + }); + + test('it returns the expected comment when `incompatible` is empty', () => { + const emptyIncompatible: PartitionedFieldMetadata = { + ...mockPartitionedFieldMetadata, + incompatible: [], // <-- empty + }; + + expect( + getAllIncompatibleMarkdownComments({ + docsCount: 4, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + partitionedFieldMetadata: emptyIncompatible, + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual([ + '### auditbeat-custom-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ✅ | auditbeat-custom-index-1 | 4 (0.0%) | 0 | `unmanaged` | 27.7KB |\n\n', + '### **Incompatible fields** `0` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + '\n\n\n', ]); }); }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.ts index 2e44ec53142b5..1f4e0b62b1c58 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/helpers.ts @@ -9,8 +9,6 @@ import { EcsVersion } from '@kbn/ecs'; import { getIncompatiableFieldsInSameFamilyCount } from '../callouts/incompatible_callout/helpers'; import { - ECS_FIELD_REFERENCE_URL, - ECS_REFERENCE_URL, getSummaryMarkdownComment, getIncompatibleMappingsMarkdownTableRows, getIncompatibleValuesMarkdownTableRows, @@ -18,7 +16,6 @@ import { getMarkdownTable, getSummaryTableMarkdownComment, getTabCountsMarkdownComment, - MAPPING_URL, } from '../../index_properties/markdown/helpers'; import { getFillColor } from '../summary_tab/helpers'; import * as i18n from '../../index_properties/translations'; @@ -106,18 +103,22 @@ ${ export const getAllIncompatibleMarkdownComments = ({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata; patternDocsCount: number; + sizeInBytes: number | undefined; }): string[] => { const incompatibleMappings = getIncompatibleMappings(partitionedFieldMetadata.incompatible); const incompatibleValues = getIncompatibleValues(partitionedFieldMetadata.incompatible); @@ -134,20 +135,16 @@ export const getAllIncompatibleMarkdownComments = ({ : ''; return [ - getSummaryMarkdownComment({ - ecsFieldReferenceUrl: ECS_FIELD_REFERENCE_URL, - ecsReferenceUrl: ECS_REFERENCE_URL, - incompatible: partitionedFieldMetadata.incompatible.length, - indexName, - mappingUrl: MAPPING_URL, - }), + getSummaryMarkdownComment(indexName), getSummaryTableMarkdownComment({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }), getTabCountsMarkdownComment(partitionedFieldMetadata), incompatibleFieldsMarkdownComment, diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx index f4def1393c07c..2fa4fcdb4f8ed 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/incompatible_tab/index.tsx @@ -13,14 +13,12 @@ import { EuiEmptyPrompt, EuiSpacer, } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useCallback, useMemo } from 'react'; import { IncompatibleCallout } from '../callouts/incompatible_callout'; import { CompareFieldsTable } from '../../../compare_fields_table'; import { getIncompatibleMappingsTableColumns } from '../../../compare_fields_table/get_incompatible_mappings_table_columns'; import { getIncompatibleValuesTableColumns } from '../../../compare_fields_table/helpers'; -import { EMPTY_STAT } from '../../../helpers'; import { EmptyPromptBody } from '../../index_properties/empty_prompt_body'; import { EmptyPromptTitle } from '../../index_properties/empty_prompt_title'; import { @@ -41,31 +39,30 @@ import type { IlmPhase, PartitionedFieldMetadata } from '../../../types'; interface Props { addSuccessToast: (toast: { title: string }) => void; addToNewCaseDisabled: boolean; - defaultNumberFormat: string; docsCount: number; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; onAddToNewCase: (markdownComments: string[]) => void; partitionedFieldMetadata: PartitionedFieldMetadata; patternDocsCount: number; + sizeInBytes: number | undefined; } const IncompatibleTabComponent: React.FC = ({ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, ilmPhase, indexName, onAddToNewCase, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }) => { - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); const body = useMemo(() => , []); const title = useMemo(() => , []); const incompatibleMappings = useMemo( @@ -80,13 +77,24 @@ const IncompatibleTabComponent: React.FC = ({ () => getAllIncompatibleMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }), - [docsCount, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount] + [ + docsCount, + formatBytes, + formatNumber, + ilmPhase, + indexName, + partitionedFieldMetadata, + patternDocsCount, + sizeInBytes, + ] ); const onClickAddToCase = useCallback( () => onAddToNewCase([markdownComments.join('\n')]), @@ -101,7 +109,7 @@ const IncompatibleTabComponent: React.FC = ({ }, [addSuccessToast, markdownComments]); return ( - <> +
{showInvalidCallout(partitionedFieldMetadata.incompatible) ? ( <> @@ -161,7 +169,7 @@ const IncompatibleTabComponent: React.FC = ({ titleSize="s" /> )} - +
); }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/styles.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/styles.tsx index cf83cf96d2812..2714d1002c40c 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/styles.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/styles.tsx @@ -5,13 +5,43 @@ * 2.0. */ -import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiButtonEmpty, EuiFlexItem, EuiLink } from '@elastic/eui'; import styled from 'styled-components'; +export const DEFAULT_LEGEND_HEIGHT = 300; // px +export const DEFAULT_MAX_CHART_HEIGHT = 300; // px + export const CalloutItem = styled.div` margin-left: ${({ theme }) => theme.eui.euiSizeS}; `; +export const ChartFlexItem = styled(EuiFlexItem)<{ + $maxChartHeight: number | undefined; + $minChartHeight: number; +}>` + ${({ $maxChartHeight }) => ($maxChartHeight != null ? `max-height: ${$maxChartHeight}px;` : '')} + min-height: ${({ $minChartHeight }) => `${$minChartHeight}px`}; +`; + export const CopyToClipboardButton = styled(EuiButtonEmpty)` margin-left: ${({ theme }) => theme.eui.euiSizeXS}; `; + +export const LegendContainer = styled.div<{ + $height?: number; + $width?: number; +}>` + margin-left: ${({ theme }) => theme.eui.euiSizeM}; + margin-top: ${({ theme }) => theme.eui.euiSizeM}; + ${({ $height }) => ($height != null ? `height: ${$height}px;` : '')} + scrollbar-width: thin; + ${({ $width }) => ($width != null ? `width: ${$width}px;` : '')} +`; + +export const StorageTreemapContainer = styled.div` + padding: ${({ theme }) => theme.eui.euiSizeM}; +`; + +export const ChartLegendLink = styled(EuiLink)` + width: 100%; +`; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx index 054ac5e003326..6d36fdd50370a 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/callout_summary/index.tsx @@ -6,14 +6,12 @@ */ import { copyToClipboard, EuiButton, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import numeral from '@elastic/numeral'; import React, { useCallback, useMemo } from 'react'; import { MissingTimestampCallout } from '../../callouts/missing_timestamp_callout'; import { IncompatibleCallout } from '../../callouts/incompatible_callout'; import { showMissingTimestampCallout } from '../../helpers'; import { getMarkdownComments } from '../helpers'; -import { EMPTY_STAT } from '../../../../helpers'; import { showInvalidCallout } from '../../incompatible_tab/helpers'; import { CopyToClipboardButton } from '../../styles'; import * as i18n from '../../../index_properties/translations'; @@ -23,52 +21,55 @@ import type { IlmPhase, PartitionedFieldMetadata } from '../../../../types'; interface Props { addSuccessToast: (toast: { title: string }) => void; addToNewCaseDisabled: boolean; - defaultNumberFormat: string; docsCount: number; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; onAddToNewCase: (markdownComment: string[]) => void; partitionedFieldMetadata: PartitionedFieldMetadata; pattern: string; patternDocsCount: number; + sizeInBytes: number | undefined; } const CalloutSummaryComponent: React.FC = ({ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, docsCount, + formatBytes, + formatNumber, ilmPhase, indexName, onAddToNewCase, partitionedFieldMetadata, pattern, patternDocsCount, + sizeInBytes, }) => { - const formatNumber = useCallback( - (value: number | undefined): string => - value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, - [defaultNumberFormat] - ); const markdownComments: string[] = useMemo( () => getMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, pattern, patternDocsCount, + sizeInBytes, }), [ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, pattern, patternDocsCount, + sizeInBytes, ] ); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.test.ts new file mode 100644 index 0000000000000..64e78a4a88cfb --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.test.ts @@ -0,0 +1,235 @@ +/* + * 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 numeral from '@elastic/numeral'; +import { EcsVersion } from '@kbn/ecs'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EMPTY_STAT } from '../../../helpers'; + +import { mockPartitionedFieldMetadata } from '../../../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { PartitionedFieldMetadata } from '../../../types'; +import { + ALL_TAB_ID, + CUSTOM_TAB_ID, + ECS_COMPLIANT_TAB_ID, + INCOMPATIBLE_TAB_ID, +} from '../../index_properties/helpers'; +import { + CUSTOM_FIELDS, + ECS_COMPLIANT_FIELDS, + INCOMPATIBLE_FIELDS, + UNKNOWN, +} from '../../index_properties/translations'; +import { + CategoryId, + getFillColor, + getMarkdownComments, + getNodeLabel, + getSummaryData, + getTabId, +} from './helpers'; + +describe('helpers', () => { + describe('getSummaryData', () => { + test('it returns the expected `SummaryData`', () => { + expect(getSummaryData(mockPartitionedFieldMetadata)).toEqual([ + { categoryId: 'incompatible', mappings: 3 }, + { categoryId: 'custom', mappings: 4 }, + { categoryId: 'ecs-compliant', mappings: 2 }, + ]); + }); + }); + + describe('getFillColor', () => { + const invalid: CategoryId = 'invalid-category-id' as CategoryId; + + const categories: Array<{ + categoryId: CategoryId; + expectedColor: string; + }> = [ + { + categoryId: 'incompatible', + expectedColor: euiThemeVars.euiColorDanger, + }, + { + categoryId: 'custom', + expectedColor: euiThemeVars.euiColorLightShade, + }, + { + categoryId: 'ecs-compliant', + expectedColor: euiThemeVars.euiColorSuccess, + }, + { + categoryId: invalid, + expectedColor: euiThemeVars.euiColorGhost, + }, + ]; + + categories.forEach(({ categoryId, expectedColor }) => { + test(`it returns the expected color for category '${categoryId}'`, () => { + expect(getFillColor(categoryId)).toEqual(expectedColor); + }); + }); + }); + + describe('getNodeLabel', () => { + const invalid: CategoryId = 'invalid-category-id' as CategoryId; + + const categories: Array<{ + categoryId: CategoryId; + expectedLabel: string; + }> = [ + { + categoryId: 'incompatible', + expectedLabel: INCOMPATIBLE_FIELDS, + }, + { + categoryId: 'custom', + expectedLabel: CUSTOM_FIELDS, + }, + { + categoryId: 'ecs-compliant', + expectedLabel: ECS_COMPLIANT_FIELDS, + }, + { + categoryId: invalid, + expectedLabel: UNKNOWN, + }, + ]; + + categories.forEach(({ categoryId, expectedLabel }) => { + test(`it returns the expected label for category '${categoryId}'`, () => { + expect(getNodeLabel(categoryId)).toEqual(expectedLabel); + }); + }); + }); + + describe('getTabId', () => { + const groupByFields: Array<{ + groupByField: string; + expectedTabId: string; + }> = [ + { + groupByField: 'incompatible', + expectedTabId: INCOMPATIBLE_TAB_ID, + }, + { + groupByField: 'custom', + expectedTabId: CUSTOM_TAB_ID, + }, + { + groupByField: 'ecs-compliant', + expectedTabId: ECS_COMPLIANT_TAB_ID, + }, + { + groupByField: 'some-other-group', + expectedTabId: ALL_TAB_ID, + }, + ]; + + groupByFields.forEach(({ groupByField, expectedTabId }) => { + test(`it returns the expected tab ID for groupByField '${groupByField}'`, () => { + expect(getTabId(groupByField)).toEqual(expectedTabId); + }); + }); + }); + + describe('getMarkdownComments', () => { + const defaultBytesFormat = '0,0.[0]b'; + const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + + const defaultNumberFormat = '0,0.[000]'; + const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + + test('it returns the expected comment when the index has incompatible fields ', () => { + expect( + getMarkdownComments({ + docsCount: 4, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'auditbeat-*', + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual([ + '### auditbeat-custom-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` | 27.7KB |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + `#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version ${EcsVersion}.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n`, + '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ]); + }); + + test('it returns an empty array when the index does NOT have incompatible fields ', () => { + const noIncompatible: PartitionedFieldMetadata = { + ...mockPartitionedFieldMetadata, + incompatible: [], // <-- no incompatible fields + }; + + expect( + getMarkdownComments({ + docsCount: 4, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-index-1', + partitionedFieldMetadata: noIncompatible, + pattern: 'auditbeat-*', + patternDocsCount: 57410, + sizeInBytes: 28413, + }) + ).toEqual([]); + }); + + test('it returns a missing timestamp comment for an empty index', () => { + const emptyIndex: PartitionedFieldMetadata = { + all: [], + ecsCompliant: [], + custom: [], + incompatible: [ + { + description: + 'Date/time when the event originated. This is the date/time extracted from the event, typically representing when the event was generated by the source. If the event source has no original timestamp, this value is typically populated by the first time the event was received by the pipeline. Required field for all events.', + hasEcsMetadata: true, + indexFieldName: '@timestamp', + indexFieldType: '-', + indexInvalidValues: [], + isEcsCompliant: false, + isInSameFamily: false, + type: 'date', + }, + ], + }; + + expect( + getMarkdownComments({ + docsCount: 0, + formatBytes, + formatNumber, + ilmPhase: 'unmanaged', + indexName: 'auditbeat-custom-empty-index-1', + partitionedFieldMetadata: emptyIndex, + pattern: 'auditbeat-*', + patternDocsCount: 57410, + sizeInBytes: 247, + }) + ).toEqual([ + '### auditbeat-custom-empty-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | auditbeat-custom-empty-index-1 | 0 (0.0%) | 1 | `unmanaged` | 247B |\n\n', + '### **Incompatible fields** `1` **Custom fields** `0` **ECS compliant fields** `0` **All fields** `0`\n', + `#### 1 incompatible field, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version ${EcsVersion}.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n`, + '\n#### Incompatible field mappings - auditbeat-custom-empty-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| @timestamp | `date` | `-` |\n\n\n', + '#### Missing an @timestamp (date) field mapping for this index\n\nConsider adding an @timestamp (date) field mapping to this index, as required by the Elastic Common Schema (ECS), because:\n\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n', + ]); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.ts index 4ab85f87e01d0..1f728e3b60c86 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/helpers.ts @@ -79,28 +79,34 @@ const isString = (x: string | null): x is string => typeof x === 'string'; export const getMarkdownComments = ({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }: { docsCount: number; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; ilmPhase: IlmPhase | undefined; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata; pattern: string; patternDocsCount: number; + sizeInBytes: number | undefined; }): string[] => { const invalidMarkdownComments = showInvalidCallout(partitionedFieldMetadata.incompatible) ? getAllIncompatibleMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }) : []; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx index 087470b7e86dc..c830ac2f6c7be 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/data_quality_panel/tabs/summary_tab/index.tsx @@ -24,8 +24,9 @@ import type { IlmPhase, PartitionedFieldMetadata } from '../../../types'; interface Props { addSuccessToast: (toast: { title: string }) => void; addToNewCaseDisabled: boolean; - defaultNumberFormat: string; docsCount: number; + formatBytes: (value: number | undefined) => string; + formatNumber: (value: number | undefined) => string; getGroupByFieldsOnClick: ( elements: Array< | FlameElementEvent @@ -46,13 +47,15 @@ interface Props { pattern: string; patternDocsCount: number; setSelectedTabId: (tabId: string) => void; + sizeInBytes: number | undefined; theme: Theme; } const SummaryTabComponent: React.FC = ({ addSuccessToast, addToNewCaseDisabled, - defaultNumberFormat, + formatBytes, + formatNumber, docsCount, getGroupByFieldsOnClick, ilmPhase, @@ -62,13 +65,15 @@ const SummaryTabComponent: React.FC = ({ pattern, patternDocsCount, setSelectedTabId, + sizeInBytes, theme, }) => ( <> = ({ partitionedFieldMetadata={partitionedFieldMetadata} pattern={pattern} patternDocsCount={patternDocsCount} + sizeInBytes={sizeInBytes} /> void; + color: string | null; + count: number | string; + dataTestSubj?: string; + onClick: (() => void) | undefined; text: string; + textWidth?: number; } -const ChartLegendItemComponent: React.FC = ({ color, count, onClick, text }) => { - return ( +const ChartLegendItemComponent: React.FC = ({ + color, + count, + dataTestSubj = DEFAULT_DATA_TEST_SUBJ, + onClick, + text, + textWidth, +}) => ( + - - - {text} - - + + {color != null ? ( + + + {text} + + + ) : ( + + {text} + + )} + + - -
{count}
-
+ {count}
- ); -}; +
+); ChartLegendItemComponent.displayName = 'ChartLegendItemComponent'; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/chart_legend/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/chart_legend/index.tsx index 21dd3e0450f40..b7c05e75300d5 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/chart_legend/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/chart_legend/index.tsx @@ -5,9 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled from 'styled-components'; import { ChartLegendItem } from './chart_legend_item'; import { getEcsCompliantColor } from '../../data_quality_panel/tabs/helpers'; @@ -20,10 +18,9 @@ import { getCustomColor } from '../../data_quality_panel/tabs/custom_tab/helpers import { getIncompatibleColor } from '../../data_quality_panel/tabs/incompatible_tab/helpers'; import type { PartitionedFieldMetadata } from '../../types'; import * as i18n from '../../data_quality_panel/index_properties/translations'; +import { LegendContainer } from '../../data_quality_panel/tabs/styles'; -const ChartLegendFlexGroup = styled(EuiFlexGroup)` - width: 210px; -`; +const LEGEND_WIDTH = 200; // px interface Props { partitionedFieldMetadata: PartitionedFieldMetadata; @@ -44,40 +41,34 @@ const ChartLegendComponent: React.FC = ({ partitionedFieldMetadata, setSe ); return ( - + {partitionedFieldMetadata.incompatible.length > 0 && ( - - - + )} {partitionedFieldMetadata.custom.length > 0 && ( - - - + )} {partitionedFieldMetadata.ecsCompliant.length > 0 && ( - - - + )} - + ); }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/helpers.test.ts new file mode 100644 index 0000000000000..30a5e9d13e4fc --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ecs_summary_donut_chart/helpers.test.ts @@ -0,0 +1,29 @@ +/* + * 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 { allMetadataIsEmpty } from './helpers'; +import { mockPartitionedFieldMetadata } from '../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; +import { PartitionedFieldMetadata } from '../types'; + +describe('helpers', () => { + describe('allMetadataIsEmpty', () => { + test('it returns false when `all` is NOT is empty', () => { + expect(allMetadataIsEmpty(mockPartitionedFieldMetadata)).toBe(false); + }); + + test('it returns true when `all` is is empty', () => { + const allIsEmpty: PartitionedFieldMetadata = { + all: [], // <-- empty + custom: [], + ecsCompliant: [], + incompatible: [], + }; + + expect(allMetadataIsEmpty(allIsEmpty)).toBe(true); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.test.ts index 14448d1a3baac..81e968bfe73a4 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.test.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.test.ts @@ -5,10 +5,7 @@ * 2.0. */ -import { - IlmExplainLifecycleLifecycleExplain, - MappingProperty, -} from '@elastic/elasticsearch/lib/api/types'; +import { IlmExplainLifecycleLifecycleExplain } from '@elastic/elasticsearch/lib/api/types'; import { EcsFlat } from '@kbn/ecs'; import { omit } from 'lodash/fp'; @@ -28,9 +25,11 @@ import { getMissingTimestampFieldMetadata, getPartitionedFieldMetadata, getPartitionedFieldMetadataStats, + getSizeInBytes, getTotalDocsCount, getTotalPatternIncompatible, getTotalPatternIndicesChecked, + getTotalSizeInBytes, hasValidTimestampMapping, isMappingCompatible, } from './helpers'; @@ -44,8 +43,9 @@ import { sourcePort, timestamp, eventCategoryWithUnallowedValues, -} from './mock/enriched_field_metadata'; -import { mockIlmExplain } from './mock/ilm_explain'; +} from './mock/enriched_field_metadata/mock_enriched_field_metadata'; +import { mockIlmExplain } from './mock/ilm_explain/mock_ilm_explain'; +import { mockMappingsProperties } from './mock/mappings_properties/mock_mappings_properties'; import { alertIndexNoResults } from './mock/pattern_rollup/mock_alerts_pattern_rollup'; import { packetbeatNoResults, @@ -79,7 +79,7 @@ const ecsMetadata: Record = EcsFlat as unknown as Record { describe('getIndexNames', () => { - const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; + const ilmPhases = ['hot', 'warm', 'unmanaged']; test('returns the expected index names when they have an ILM phase included in the ilmPhases list', () => { expect( @@ -91,17 +91,18 @@ describe('helpers', () => { ).toEqual([ '.ds-packetbeat-8.6.1-2023.02.04-000001', '.ds-packetbeat-8.5.3-2023.02.04-000001', + 'auditbeat-custom-index-1', ]); }); test('returns the expected filtered index names when they do NOT have an ILM phase included in the ilmPhases list', () => { expect( getIndexNames({ - ilmExplain: mockIlmExplain, // <-- the mock indexes have 'hot' ILM phases... + ilmExplain: mockIlmExplain, // <-- the mock indexes have 'hot' and 'unmanaged' ILM phases... ilmPhases: ['warm', 'unmanaged'], // <-- ...but we don't ask for 'hot' stats: mockStats, }) - ).toEqual([]); + ).toEqual(['auditbeat-custom-index-1']); // <-- the 'unmanaged' index }); test('returns the expected index names when the `ilmExplain` is missing a record for an index', () => { @@ -117,7 +118,7 @@ describe('helpers', () => { ilmPhases: ['hot', 'warm', 'unmanaged'], stats: mockStats, }) - ).toEqual(['.ds-packetbeat-8.5.3-2023.02.04-000001']); // <-- only includes one of the two indexes, because the other one is missing an ILM explain record + ).toEqual(['.ds-packetbeat-8.5.3-2023.02.04-000001', 'auditbeat-custom-index-1']); // <-- only includes two of the three indices, because the other one is missing an ILM explain record }); test('returns empty index names when `ilmPhases` is empty', () => { @@ -162,105 +163,6 @@ describe('helpers', () => { }); describe('getFieldTypes', () => { - /** - * These `mappingsProperties` represent mappings that were generated by - * Elasticsearch automatically, for an index named `auditbeat-custom-index-1`: - * - * ``` - * DELETE auditbeat-custom-index-1 - * - * PUT auditbeat-custom-index-1 - * - * PUT auditbeat-custom-index-1/_mapping - * { - * "properties": { - * "@timestamp": { - * "type": "date" - * }, - * "event.category": { - * "type": "keyword", - * "ignore_above": 1024 - * } - * } - * } - * ``` - * - * when the following document was inserted: - * - * ``` - * POST auditbeat-custom-index-1/_doc - * { - * "@timestamp": "2023-02-06T09:41:49.668Z", - * "host": { - * "name": "foo" - * }, - * "event": { - * "category": "an_invalid_category" - * }, - * "some.field": "this", - * "source": { - * "port": 90210, - * "ip": "10.1.2.3" - * } - * } - * ``` - */ - const mappingsProperties: Record = { - '@timestamp': { - type: 'date', - }, - event: { - properties: { - category: { - type: 'keyword', - ignore_above: 1024, - }, - }, - }, - host: { - properties: { - name: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - ignore_above: 256, - }, - }, - }, - }, - }, - some: { - properties: { - field: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - ignore_above: 256, - }, - }, - }, - }, - }, - source: { - properties: { - ip: { - type: 'text', - fields: { - keyword: { - type: 'keyword', - ignore_above: 256, - }, - }, - }, - port: { - type: 'long', - }, - }, - }, - }; - const expected = [ { field: '@timestamp', @@ -301,7 +203,7 @@ describe('helpers', () => { ]; test('it flattens the field names and types in the mapping properties', () => { - expect(getFieldTypes(mappingsProperties)).toEqual(expected); + expect(getFieldTypes(mockMappingsProperties)).toEqual(expected); }); test('it throws a type error when mappingsProperties is not flatten-able', () => { @@ -876,6 +778,53 @@ describe('helpers', () => { }); }); + describe('getSizeInBytes', () => { + test('it returns the expected size when `stats` contains the `indexName`', () => { + const indexName = '.ds-packetbeat-8.6.1-2023.02.04-000001'; + const expectedCount = mockStatsYellowIndex[indexName].primaries?.store?.size_in_bytes; + + expect( + getSizeInBytes({ + indexName, + stats: mockStatsYellowIndex, + }) + ).toEqual(expectedCount); + }); + + test('it returns zero when `stats` does NOT contain the `indexName`', () => { + const indexName = 'not-gonna-find-it'; + + expect( + getSizeInBytes({ + indexName, + stats: mockStatsYellowIndex, + }) + ).toEqual(0); + }); + + test('it returns zero when `stats` is null', () => { + const indexName = '.ds-packetbeat-8.6.1-2023.02.04-000001'; + + expect( + getSizeInBytes({ + indexName, + stats: null, + }) + ).toEqual(0); + }); + + test('it returns the expected size for a green index, where `primaries.store.size_in_bytes` and `total.store.size_in_bytes` have different values', () => { + const indexName = 'auditbeat-custom-index-1'; + + expect( + getSizeInBytes({ + indexName, + stats: mockStatsGreenIndex, + }) + ).toEqual(mockStatsGreenIndex[indexName].primaries?.store?.size_in_bytes); + }); + }); + describe('getTotalDocsCount', () => { test('it returns the expected total given a subset of index names in the stats', () => { const indexName = '.ds-packetbeat-8.5.3-2023.02.04-000001'; @@ -925,6 +874,55 @@ describe('helpers', () => { }); }); + describe('getTotalSizeInBytes', () => { + test('it returns the expected total given a subset of index names in the stats', () => { + const indexName = '.ds-packetbeat-8.5.3-2023.02.04-000001'; + const expectedCount = mockStatsYellowIndex[indexName].primaries?.store?.size_in_bytes; + + expect( + getTotalSizeInBytes({ + indexNames: [indexName], + stats: mockStatsYellowIndex, + }) + ).toEqual(expectedCount); + }); + + test('it returns the expected total given all index names in the stats', () => { + const allIndexNamesInStats = [ + '.ds-packetbeat-8.6.1-2023.02.04-000001', + '.ds-packetbeat-8.5.3-2023.02.04-000001', + ]; + + expect( + getTotalSizeInBytes({ + indexNames: allIndexNamesInStats, + stats: mockStatsYellowIndex, + }) + ).toEqual(1464758182); + }); + + test('it returns zero given an empty collection of index names', () => { + expect( + getTotalSizeInBytes({ + indexNames: [], // <-- empty + stats: mockStatsYellowIndex, + }) + ).toEqual(0); + }); + + test('it returns the expected total for a green index', () => { + const indexName = 'auditbeat-custom-index-1'; + const expectedCount = mockStatsGreenIndex[indexName].primaries?.store?.size_in_bytes; + + expect( + getTotalSizeInBytes({ + indexNames: [indexName], + stats: mockStatsGreenIndex, + }) + ).toEqual(expectedCount); + }); + }); + describe('getIlmPhaseDescription', () => { const phases: Array<{ phase: string; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.ts index ea8a50a41580f..7cb638ad11550 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/helpers.ts @@ -255,6 +255,14 @@ export const getDocsCount = ({ stats: Record | null; }): number => (stats && stats[indexName]?.primaries?.docs?.count) ?? 0; +export const getSizeInBytes = ({ + indexName, + stats, +}: { + indexName: string; + stats: Record | null; +}): number => (stats && stats[indexName]?.primaries?.store?.size_in_bytes) ?? 0; + export const getTotalDocsCount = ({ indexNames, stats, @@ -267,6 +275,18 @@ export const getTotalDocsCount = ({ 0 ); +export const getTotalSizeInBytes = ({ + indexNames, + stats, +}: { + indexNames: string[]; + stats: Record | null; +}): number => + indexNames.reduce( + (acc: number, indexName: string) => acc + getSizeInBytes({ stats, indexName }), + 0 + ); + export const EMPTY_STAT = '--'; /** diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ilm_phases_empty_prompt/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ilm_phases_empty_prompt/index.test.tsx new file mode 100644 index 0000000000000..417f1419a7ca5 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/ilm_phases_empty_prompt/index.test.tsx @@ -0,0 +1,28 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../mock/test_providers/test_providers'; +import { IlmPhasesEmptyPrompt } from '.'; + +describe('IlmPhasesEmptyPrompt', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders the expected content', () => { + expect(screen.getByTestId('ilmPhasesEmptyPrompt')).toHaveTextContent( + "ILM phases that can be checked for data qualityhot: The index is actively being updated and queriedwarm: The index is no longer being updated but is still being queriedunmanaged: The index isn't managed by Index Lifecycle Management (ILM)ILM phases that cannot be checkedThe following ILM phases cannot be checked for data quality because they are slower to accesscold: The index is no longer being updated and is queried infrequently. The information still needs to be searchable, but it’s okay if those queries are slower.frozen: The index is no longer being updated and is queried rarely. The information still needs to be searchable, but it's okay if those queries are extremely slow." + ); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.test.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.test.tsx index 72affc8aec491..5f6711814a904 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.test.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.test.tsx @@ -9,12 +9,12 @@ import { DARK_THEME } from '@elastic/charts'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from './mock/test_providers'; +import { TestProviders } from './mock/test_providers/test_providers'; import { DataQualityPanel } from '.'; describe('DataQualityPanel', () => { - describe('when no ILM phases are provided', () => { - const ilmPhases: string[] = []; + describe('when ILM phases are provided', () => { + const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; beforeEach(() => { render( @@ -22,6 +22,7 @@ describe('DataQualityPanel', () => { { ); }); - test('it renders the ILM phases empty prompt', () => { - expect(screen.getByTestId('ilmPhasesEmptyPrompt')).toBeInTheDocument(); + test('it does NOT render the ILM phases empty prompt', () => { + expect(screen.queryByTestId('ilmPhasesEmptyPrompt')).not.toBeInTheDocument(); }); - test('it does NOT render the body', () => { - expect(screen.queryByTestId('body')).not.toBeInTheDocument(); + test('it renders the body', () => { + expect(screen.getByTestId('body')).toBeInTheDocument(); }); }); - describe('when ILM phases are provided', () => { - const ilmPhases: string[] = ['hot', 'warm', 'unmanaged']; + describe('when ILM phases are NOT provided', () => { + test('it renders the ILM phases empty prompt', () => { + const ilmPhases: string[] = []; - beforeEach(() => { render( { /> ); - }); - - test('it does NOT render the ILM phases empty prompt', () => { - expect(screen.queryByTestId('ilmPhasesEmptyPrompt')).not.toBeInTheDocument(); - }); - test('it renders the body', () => { - expect(screen.getByTestId('body')).toBeInTheDocument(); + expect(screen.getByTestId('ilmPhasesEmptyPrompt')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.tsx index 05ccaf9c4fc5e..dde54d8e97f64 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/index.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import numeral from '@elastic/numeral'; import type { FlameElementEvent, HeatmapElementEvent, @@ -14,15 +15,16 @@ import type { WordCloudElementEvent, XYChartElementEvent, } from '@elastic/charts'; -import React from 'react'; +import React, { useCallback } from 'react'; import { Body } from './data_quality_panel/body'; -import { IlmPhasesEmptyPrompt } from './ilm_phases_empty_prompt'; +import { EMPTY_STAT } from './helpers'; interface Props { addSuccessToast: (toast: { title: string }) => void; canUserCreateAndReadCases: () => boolean; defaultNumberFormat: string; + defaultBytesFormat: string; getGroupByFieldsOnClick: ( elements: Array< | FlameElementEvent @@ -54,6 +56,7 @@ interface Props { const DataQualityPanelComponent: React.FC = ({ addSuccessToast, canUserCreateAndReadCases, + defaultBytesFormat, defaultNumberFormat, getGroupByFieldsOnClick, ilmPhases, @@ -63,15 +66,24 @@ const DataQualityPanelComponent: React.FC = ({ setLastChecked, theme, }) => { - if (ilmPhases.length === 0) { - return ; - } + const formatBytes = useCallback( + (value: number | undefined): string => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT, + [defaultBytesFormat] + ); + + const formatNumber = useCallback( + (value: number | undefined): string => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT, + [defaultNumberFormat] + ); return ( = { + 'auditbeat-custom-index-1': { + docsCount: 4, + error: null, + ilmPhase: 'unmanaged', + incompatible: 3, + indexName: 'auditbeat-custom-index-1', + markdownComments: [ + '### auditbeat-custom-index-1\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase |\n|--------|-------|------|---------------------|-----------|\n| ❌ | auditbeat-custom-index-1 | 4 (0.0%) | 3 | `unmanaged` |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - auditbeat-custom-index-1\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - auditbeat-custom-index-1\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2),\n`theory` (1) |\n\n', + ], + pattern: 'auditbeat-*', + }, + 'auditbeat-7.9.3-2023.02.13-000001': { + docsCount: 2438, + error: null, + ilmPhase: 'hot', + incompatible: 12, + indexName: 'auditbeat-7.9.3-2023.02.13-000001', + markdownComments: [ + '### auditbeat-7.9.3-2023.02.13-000001\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase |\n|--------|-------|------|---------------------|-----------|\n| ❌ | auditbeat-7.9.3-2023.02.13-000001 | 2,438 (4.2%) | 12 | `hot` |\n\n', + '### **Incompatible fields** `12` **Custom fields** `439` **ECS compliant fields** `506` **All fields** `957`\n', + "#### 12 incompatible fields, 11 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - auditbeat-7.9.3-2023.02.13-000001\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| error.message | `match_only_text` | `text` `same family` |\n| error.stack_trace | `wildcard` | `keyword` `same family` |\n| http.request.body.content | `wildcard` | `keyword` `same family` |\n| http.response.body.content | `wildcard` | `keyword` `same family` |\n| message | `match_only_text` | `text` `same family` |\n| process.command_line | `wildcard` | `keyword` `same family` |\n| process.parent.command_line | `wildcard` | `keyword` `same family` |\n| registry.data.strings | `wildcard` | `keyword` `same family` |\n| url.full | `wildcard` | `keyword` `same family` |\n| url.original | `wildcard` | `keyword` `same family` |\n| url.path | `wildcard` | `keyword` `same family` |\n\n#### Incompatible field values - auditbeat-7.9.3-2023.02.13-000001\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.kind | `alert`, `enrichment`, `event`, `metric`, `state`, `pipeline_error`, `signal` | `error` (7) |\n\n', + ], + pattern: 'auditbeat-*', + }, +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/index.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/mock_enriched_field_metadata.ts similarity index 87% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/index.ts rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/mock_enriched_field_metadata.ts index 654d608d7d351..be445b6ee6b51 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/index.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/enriched_field_metadata/mock_enriched_field_metadata.ts @@ -270,3 +270,75 @@ export const sourcePort: EnrichedFieldMetadata = { isEcsCompliant: true, isInSameFamily: false, // `long` is not a member of any families }; + +export const mockCustomFields: EnrichedFieldMetadata[] = [ + { + indexFieldName: 'host.name.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'some.field.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + indexFieldName: 'source.ip.keyword', + indexFieldType: 'keyword', + indexInvalidValues: [], + hasEcsMetadata: false, + isEcsCompliant: false, + isInSameFamily: false, + }, +]; + +export const mockIncompatibleMappings: EnrichedFieldMetadata[] = [ + { + dashed_name: 'host-name', + description: + 'Name of the host.\nIt can contain what `hostname` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use.', + flat_name: 'host.name', + ignore_above: 1024, + level: 'core', + name: 'name', + normalize: [], + short: 'Name of the host.', + type: 'keyword', + indexFieldName: 'host.name', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, + { + dashed_name: 'source-ip', + description: 'IP address of the source (IPv4 or IPv6).', + flat_name: 'source.ip', + level: 'core', + name: 'ip', + normalize: [], + short: 'IP address of the source.', + type: 'ip', + indexFieldName: 'source.ip', + indexFieldType: 'text', + indexInvalidValues: [], + hasEcsMetadata: true, + isEcsCompliant: false, + isInSameFamily: false, + }, +]; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/index.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/mock_ilm_explain.ts similarity index 94% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/index.ts rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/mock_ilm_explain.ts index bf7bc667c9aaf..b5b35064a7320 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/index.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/ilm_explain/mock_ilm_explain.ts @@ -48,4 +48,8 @@ export const mockIlmExplain: Record modified_date_in_millis: 1675536751205, }, }, + 'auditbeat-custom-index-1': { + index: 'auditbeat-custom-index-1', + managed: false, + }, }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/indices_get_mapping_index_mapping_record/mock_indices_get_mapping_index_mapping_record.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/indices_get_mapping_index_mapping_record/mock_indices_get_mapping_index_mapping_record.ts new file mode 100644 index 0000000000000..e63bae0530961 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/indices_get_mapping_index_mapping_record/mock_indices_get_mapping_index_mapping_record.ts @@ -0,0 +1,73 @@ +/* + * 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 { IndicesGetMappingIndexMappingRecord } from '@elastic/elasticsearch/lib/api/types'; + +export const mockIndicesGetMappingIndexMappingRecords: Record< + string, + IndicesGetMappingIndexMappingRecord +> = { + 'auditbeat-custom-index-1': { + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + event: { + properties: { + category: { + type: 'keyword', + ignore_above: 1024, + }, + }, + }, + host: { + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + some: { + properties: { + field: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + source: { + properties: { + ip: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + port: { + type: 'long', + }, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_properties/mock_mappings_properties.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_properties/mock_mappings_properties.ts new file mode 100644 index 0000000000000..42b22d9c99aaa --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_properties/mock_mappings_properties.ts @@ -0,0 +1,107 @@ +/* + * 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 { MappingProperty } from '@elastic/elasticsearch/lib/api/types'; + +/** + * These `mappingsProperties` represent mappings that were generated by + * Elasticsearch automatically, for an index named `auditbeat-custom-index-1`: + * + * ``` + * DELETE auditbeat-custom-index-1 + * + * PUT auditbeat-custom-index-1 + * + * PUT auditbeat-custom-index-1/_mapping + * { + * "properties": { + * "@timestamp": { + * "type": "date" + * }, + * "event.category": { + * "type": "keyword", + * "ignore_above": 1024 + * } + * } + * } + * ``` + * + * when the following document was inserted: + * + * ``` + * POST auditbeat-custom-index-1/_doc + * { + * "@timestamp": "2023-02-06T09:41:49.668Z", + * "host": { + * "name": "foo" + * }, + * "event": { + * "category": "an_invalid_category" + * }, + * "some.field": "this", + * "source": { + * "port": 90210, + * "ip": "10.1.2.3" + * } + * } + * ``` + */ +export const mockMappingsProperties: Record = { + '@timestamp': { + type: 'date', + }, + event: { + properties: { + category: { + type: 'keyword', + ignore_above: 1024, + }, + }, + }, + host: { + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + some: { + properties: { + field: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + source: { + properties: { + ip: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + port: { + type: 'long', + }, + }, + }, +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_response/mock_mappings_response.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_response/mock_mappings_response.ts new file mode 100644 index 0000000000000..5e15bb4e2efdd --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/mappings_response/mock_mappings_response.ts @@ -0,0 +1,68 @@ +/* + * 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 const mockMappingsResponse = { + 'auditbeat-custom-index-1': { + mappings: { + properties: { + '@timestamp': { + type: 'date', + }, + event: { + properties: { + category: { + type: 'keyword', + ignore_above: 1024, + }, + }, + }, + host: { + properties: { + name: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + some: { + properties: { + field: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + }, + }, + source: { + properties: { + ip: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + ignore_above: 256, + }, + }, + }, + port: { + type: 'long', + }, + }, + }, + }, + }, + }, +}; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_alerts_pattern_rollup.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_alerts_pattern_rollup.ts index cbca7ab9e1965..39c25cbc77c10 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_alerts_pattern_rollup.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_alerts_pattern_rollup.ts @@ -33,6 +33,7 @@ export const alertIndexNoResults: PatternRollup = { indices: 1, pattern: '.alerts-security.alerts-default', results: undefined, // <-- no results + sizeInBytes: 6423408623, stats: { '.internal.alerts-security.alerts-default-000001': { health: 'green', @@ -83,6 +84,7 @@ export const alertIndexWithAllResults: PatternRollup = { pattern: '.alerts-security.alerts-default', }, }, + sizeInBytes: 29717961631, stats: { '.internal.alerts-security.alerts-default-000001': { health: 'green', diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts index 776f02f9f4e74..3ece4fb4c248f 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_auditbeat_pattern_rollup.ts @@ -45,10 +45,17 @@ export const auditbeatNoResults: PatternRollup = { indices: 3, pattern: 'auditbeat-*', results: undefined, // <-- no results + sizeInBytes: 18820446, stats: { '.ds-auditbeat-8.6.1-2023.02.07-000001': { uuid: 'YpxavlUVTw2x_E_QtADrpg', health: 'yellow', + primaries: { + store: { + size_in_bytes: 18791790, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -60,6 +67,12 @@ export const auditbeatNoResults: PatternRollup = { 'auditbeat-custom-empty-index-1': { uuid: 'Iz5FJjsLQla34mD6kBAQBw', health: 'yellow', + primaries: { + store: { + size_in_bytes: 247, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -71,6 +84,12 @@ export const auditbeatNoResults: PatternRollup = { 'auditbeat-custom-index-1': { uuid: 'xJvgb2QCQPSjlr7UnW8tFA', health: 'yellow', + primaries: { + store: { + size_in_bytes: 28409, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -152,10 +171,17 @@ export const auditbeatWithAllResults: PatternRollup = { pattern: 'auditbeat-*', }, }, + sizeInBytes: 18820446, stats: { '.ds-auditbeat-8.6.1-2023.02.07-000001': { uuid: 'YpxavlUVTw2x_E_QtADrpg', health: 'yellow', + primaries: { + store: { + size_in_bytes: 18791790, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -167,6 +193,12 @@ export const auditbeatWithAllResults: PatternRollup = { 'auditbeat-custom-empty-index-1': { uuid: 'Iz5FJjsLQla34mD6kBAQBw', health: 'yellow', + primaries: { + store: { + size_in_bytes: 247, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -178,6 +210,12 @@ export const auditbeatWithAllResults: PatternRollup = { 'auditbeat-custom-index-1': { uuid: 'xJvgb2QCQPSjlr7UnW8tFA', health: 'yellow', + primaries: { + store: { + size_in_bytes: 28409, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_packetbeat_pattern_rollup.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_packetbeat_pattern_rollup.ts index 2b39901b9c954..f26ef180a3641 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_packetbeat_pattern_rollup.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/pattern_rollup/mock_packetbeat_pattern_rollup.ts @@ -43,10 +43,17 @@ export const packetbeatNoResults: PatternRollup = { indices: 2, pattern: 'packetbeat-*', results: undefined, + sizeInBytes: 1096520898, stats: { '.ds-packetbeat-8.6.1-2023.02.04-000001': { uuid: 'x5Uuw4j4QM2YidHLNixCwg', health: 'yellow', + primaries: { + store: { + size_in_bytes: 512194751, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -58,6 +65,12 @@ export const packetbeatNoResults: PatternRollup = { '.ds-packetbeat-8.5.3-2023.02.04-000001': { uuid: 'we0vNWm2Q6iz6uHubyHS6Q', health: 'yellow', + primaries: { + store: { + size_in_bytes: 584326147, + reserved_in_bytes: 0, + }, + }, status: 'open', total: { docs: { @@ -127,27 +140,650 @@ export const packetbeatWithSomeErrors: PatternRollup = { pattern: 'packetbeat-*', }, }, + sizeInBytes: 1096520898, + stats: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + uuid: 'x5Uuw4j4QM2YidHLNixCwg', + health: 'yellow', + primaries: { + store: { + size_in_bytes: 512194751, + reserved_in_bytes: 0, + }, + }, + status: 'open', + total: { + docs: { + count: 1628343, + deleted: 0, + }, + }, + }, + '.ds-packetbeat-8.5.3-2023.02.04-000001': { + uuid: 'we0vNWm2Q6iz6uHubyHS6Q', + health: 'yellow', + primaries: { + store: { + size_in_bytes: 584326147, + reserved_in_bytes: 0, + }, + }, + status: 'open', + total: { + docs: { + count: 1630289, + deleted: 0, + }, + }, + }, + }, +}; + +export const mockPacketbeatPatternRollup: PatternRollup = { + docsCount: 3258632, + error: null, + ilmExplain: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536751379, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + '.ds-packetbeat-8.5.3-2023.02.04-000001': { + index: '.ds-packetbeat-8.5.3-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536774084, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536774084, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536774416, + action: 'rollover', + action_time_millis: 1675536774416, + step: 'check-rollover-ready', + step_time_millis: 1675536774416, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + }, + ilmExplainPhaseCounts: { + hot: 2, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 2, + pattern: 'packetbeat-*', + results: undefined, + sizeInBytes: 1464758182, stats: { '.ds-packetbeat-8.6.1-2023.02.04-000001': { uuid: 'x5Uuw4j4QM2YidHLNixCwg', health: 'yellow', status: 'open', + primaries: { + docs: { + count: 1628343, + deleted: 0, + }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 731583142, + total_data_set_size_in_bytes: 731583142, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 120726, + query_time_in_millis: 234865, + query_current: 0, + fetch_total: 109324, + fetch_time_in_millis: 500584, + fetch_current: 0, + scroll_total: 10432, + scroll_time_in_millis: 3874632, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 1, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 1, + }, + query_cache: { + memory_size_in_bytes: 8316098, + total_count: 34248343, + hit_count: 3138879, + miss_count: 31109464, + cache_size: 4585, + cache_count: 4585, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 12424, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 19, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 304, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 606298841, + }, + request_cache: { + memory_size_in_bytes: 89216, + evictions: 0, + hit_count: 704, + miss_count: 38, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, + }, total: { docs: { count: 1628343, deleted: 0, }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 731583142, + total_data_set_size_in_bytes: 731583142, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 120726, + query_time_in_millis: 234865, + query_current: 0, + fetch_total: 109324, + fetch_time_in_millis: 500584, + fetch_current: 0, + scroll_total: 10432, + scroll_time_in_millis: 3874632, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 1, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 1, + }, + query_cache: { + memory_size_in_bytes: 8316098, + total_count: 34248343, + hit_count: 3138879, + miss_count: 31109464, + cache_size: 4585, + cache_count: 4585, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 12424, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 19, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 304, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 606298841, + }, + request_cache: { + memory_size_in_bytes: 89216, + evictions: 0, + hit_count: 704, + miss_count: 38, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, }, }, '.ds-packetbeat-8.5.3-2023.02.04-000001': { uuid: 'we0vNWm2Q6iz6uHubyHS6Q', health: 'yellow', status: 'open', + primaries: { + docs: { + count: 1630289, + deleted: 0, + }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 733175040, + total_data_set_size_in_bytes: 733175040, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 120726, + query_time_in_millis: 248138, + query_current: 0, + fetch_total: 109484, + fetch_time_in_millis: 500514, + fetch_current: 0, + scroll_total: 10432, + scroll_time_in_millis: 3871379, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 2, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 1, + }, + query_cache: { + memory_size_in_bytes: 5387543, + total_count: 24212135, + hit_count: 2223357, + miss_count: 21988778, + cache_size: 3275, + cache_count: 3275, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 12336, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 20, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 320, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 606298805, + }, + request_cache: { + memory_size_in_bytes: 89320, + evictions: 0, + hit_count: 704, + miss_count: 38, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, + }, total: { docs: { count: 1630289, deleted: 0, }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 733175040, + total_data_set_size_in_bytes: 733175040, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 120726, + query_time_in_millis: 248138, + query_current: 0, + fetch_total: 109484, + fetch_time_in_millis: 500514, + fetch_current: 0, + scroll_total: 10432, + scroll_time_in_millis: 3871379, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 2, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 1, + }, + query_cache: { + memory_size_in_bytes: 5387543, + total_count: 24212135, + hit_count: 2223357, + miss_count: 21988778, + cache_size: 3275, + cache_count: 3275, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 12336, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 20, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 320, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 606298805, + }, + request_cache: { + memory_size_in_bytes: 89320, + evictions: 0, + hit_count: 704, + miss_count: 38, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, }, }, }, diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/stats/mock_stats.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/stats/mock_stats.tsx index 0362dcd70a53f..14465e815ad47 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/stats/mock_stats.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/stats/mock_stats.tsx @@ -558,4 +558,279 @@ export const mockStats: Record = { }, }, }, + 'auditbeat-custom-index-1': { + uuid: 'uyJDDqGrRQqdBTN0mCF-iw', + health: 'yellow', + status: 'open', + primaries: { + docs: { + count: 4, + deleted: 0, + }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 28413, + total_data_set_size_in_bytes: 28413, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 24, + query_time_in_millis: 5, + query_current: 0, + fetch_total: 24, + fetch_time_in_millis: 0, + fetch_current: 0, + scroll_total: 0, + scroll_time_in_millis: 0, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 0, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 0, + }, + query_cache: { + memory_size_in_bytes: 58, + total_count: 0, + hit_count: 0, + miss_count: 0, + cache_size: 0, + cache_count: 0, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 608, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 4, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 0, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 79289897, + }, + request_cache: { + memory_size_in_bytes: 3760, + evictions: 0, + hit_count: 20, + miss_count: 4, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, + }, + total: { + docs: { + count: 4, + deleted: 0, + }, + shard_stats: { + total_count: 1, + }, + store: { + size_in_bytes: 28413, + total_data_set_size_in_bytes: 28413, + reserved_in_bytes: 0, + }, + indexing: { + index_total: 0, + index_time_in_millis: 0, + index_current: 0, + index_failed: 0, + delete_total: 0, + delete_time_in_millis: 0, + delete_current: 0, + noop_update_total: 0, + is_throttled: false, + throttle_time_in_millis: 0, + }, + get: { + total: 0, + time_in_millis: 0, + exists_total: 0, + exists_time_in_millis: 0, + missing_total: 0, + missing_time_in_millis: 0, + current: 0, + }, + search: { + open_contexts: 0, + query_total: 24, + query_time_in_millis: 5, + query_current: 0, + fetch_total: 24, + fetch_time_in_millis: 0, + fetch_current: 0, + scroll_total: 0, + scroll_time_in_millis: 0, + scroll_current: 0, + suggest_total: 0, + suggest_time_in_millis: 0, + suggest_current: 0, + }, + merges: { + current: 0, + current_docs: 0, + current_size_in_bytes: 0, + total: 0, + total_time_in_millis: 0, + total_docs: 0, + total_size_in_bytes: 0, + total_stopped_time_in_millis: 0, + total_throttled_time_in_millis: 0, + total_auto_throttle_in_bytes: 20971520, + }, + refresh: { + total: 2, + total_time_in_millis: 0, + external_total: 2, + external_total_time_in_millis: 0, + listeners: 0, + }, + flush: { + total: 1, + periodic: 1, + total_time_in_millis: 0, + }, + warmer: { + current: 0, + total: 1, + total_time_in_millis: 0, + }, + query_cache: { + memory_size_in_bytes: 58, + total_count: 0, + hit_count: 0, + miss_count: 0, + cache_size: 0, + cache_count: 0, + evictions: 0, + }, + fielddata: { + memory_size_in_bytes: 608, + evictions: 0, + }, + completion: { + size_in_bytes: 0, + }, + segments: { + count: 4, + memory_in_bytes: 0, + terms_memory_in_bytes: 0, + stored_fields_memory_in_bytes: 0, + term_vectors_memory_in_bytes: 0, + norms_memory_in_bytes: 0, + points_memory_in_bytes: 0, + doc_values_memory_in_bytes: 0, + index_writer_memory_in_bytes: 0, + version_map_memory_in_bytes: 0, + fixed_bit_set_memory_in_bytes: 0, + max_unsafe_auto_id_timestamp: -1, + file_sizes: {}, + }, + translog: { + operations: 0, + size_in_bytes: 55, + uncommitted_operations: 0, + uncommitted_size_in_bytes: 55, + earliest_last_modified_age: 79289897, + }, + request_cache: { + memory_size_in_bytes: 3760, + evictions: 0, + hit_count: 20, + miss_count: 4, + }, + recovery: { + current_as_source: 0, + current_as_target: 0, + throttle_time_in_millis: 0, + }, + bulk: { + total_operations: 0, + total_time_in_millis: 0, + total_size_in_bytes: 0, + avg_time_in_millis: 0, + avg_size_in_bytes: 0, + }, + }, + }, }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/test_providers/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/test_providers/test_providers.tsx similarity index 100% rename from x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/test_providers/index.tsx rename to x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/test_providers/test_providers.tsx diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/unallowed_values/mock_unallowed_values.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/unallowed_values/mock_unallowed_values.ts new file mode 100644 index 0000000000000..393dd15ce9a20 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/mock/unallowed_values/mock_unallowed_values.ts @@ -0,0 +1,122 @@ +/* + * 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 const mockUnallowedValuesResponse = [ + { + took: 1, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 3, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + 'event.category': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'an_invalid_category', + doc_count: 2, + }, + { + key: 'theory', + doc_count: 1, + }, + ], + }, + }, + status: 200, + }, + { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 4, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + 'event.kind': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + status: 200, + }, + { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 4, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + 'event.outcome': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + status: 200, + }, + { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 4, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + 'event.type': { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, + status: 200, + }, +]; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/styles.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/styles.tsx index d54ea9d6316e2..6fbf130d01b8f 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/styles.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/styles.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiCode } from '@elastic/eui'; +import { EuiCode, EuiText } from '@elastic/eui'; import { euiThemeVars } from '@kbn/ui-theme'; import styled from 'styled-components'; @@ -21,3 +21,10 @@ export const CodeSuccess = styled(EuiCode)` export const CodeWarning = styled(EuiCode)` color: ${euiThemeVars.euiColorWarning}; `; + +export const FixedWidthLegendText = styled(EuiText)<{ + $width: number | undefined; +}>` + text-align: left; + ${({ $width }) => ($width != null ? `width: ${$width}px;` : '')} +`; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/translations.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/translations.ts index 771faa301cee3..53bedadd9361d 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/translations.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/translations.ts @@ -25,15 +25,6 @@ export const CHECKING = (index: string) => defaultMessage: 'Checking {index}', }); -export const COLLAPSE_BUTTON_LABEL = (collapsed: boolean) => - collapsed - ? i18n.translate('ecsDataQualityDashboard.collapseButtonLabelOpen', { - defaultMessage: 'Open', - }) - : i18n.translate('ecsDataQualityDashboard.collapseButtonLabelClosed', { - defaultMessage: 'Closed', - }); - export const COLD_DESCRIPTION = i18n.translate('ecsDataQualityDashboard.coldDescription', { defaultMessage: 'The index is no longer being updated and is queried infrequently. The information still needs to be searchable, but it’s okay if those queries are slower.', @@ -80,12 +71,6 @@ export const ECS_VERSION = i18n.translate('ecsDataQualityDashboard.ecsVersionSta defaultMessage: 'ECS version', }); -export const ERROR_LOADING_ECS_METADATA = (details: string) => - i18n.translate('ecsDataQualityDashboard.errorLoadingEcsMetadataLabel', { - values: { details }, - defaultMessage: 'Error loading ECS metadata: {details}', - }); - export const ERROR_LOADING_ECS_METADATA_TITLE = i18n.translate( 'ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingEcsMetadataTitle', { @@ -93,12 +78,6 @@ export const ERROR_LOADING_ECS_METADATA_TITLE = i18n.translate( } ); -export const ERROR_LOADING_ECS_VERSION = (details: string) => - i18n.translate('ecsDataQualityDashboard.errorLoadingEcsVersionLabel', { - values: { details }, - defaultMessage: 'Error loading ECS version: {details}', - }); - export const ERROR_LOADING_ECS_VERSION_TITLE = i18n.translate( 'ecsDataQualityDashboard.emptyErrorPrompt.errorLoadingEcsVersionTitle', { @@ -214,6 +193,10 @@ export const SELECT_ONE_OR_MORE_ILM_PHASES: string = i18n.translate( } ); +export const INDEX_SIZE_TOOLTIP = i18n.translate('ecsDataQualityDashboard.indexSizeTooltip', { + defaultMessage: 'The size of the primary index (does not include replicas)', +}); + export const TECHNICAL_PREVIEW = i18n.translate('ecsDataQualityDashboard.technicalPreviewBadge', { defaultMessage: 'Technical preview', }); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/types.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/types.ts index 9edc76bbe6220..d51e908bd7b38 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/types.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/types.ts @@ -10,6 +10,7 @@ import type { IndicesGetMappingIndexMappingRecord, IndicesStatsIndicesStats, } from '@elastic/elasticsearch/lib/api/types'; +import type { Direction } from '@elastic/eui'; export interface Mappings { pattern: string; @@ -113,6 +114,7 @@ export interface PatternRollup { indices: number | undefined; pattern: string; results: Record | undefined; + sizeInBytes: number | undefined; stats: Record | null; } @@ -139,6 +141,7 @@ export interface IndexToCheck { export type OnCheckCompleted = ({ error, + formatBytes, formatNumber, indexName, partitionedFieldMetadata, @@ -146,6 +149,7 @@ export type OnCheckCompleted = ({ version, }: { error: string | null; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata | null; @@ -158,3 +162,15 @@ export interface ErrorSummary { indexName: string | null; pattern: string; } + +export interface SortConfig { + sort: { + direction: Direction; + field: string; + }; +} + +export interface SelectedIndex { + indexName: string; + pattern: string; +} diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_mappings/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_mappings/helpers.test.ts new file mode 100644 index 0000000000000..e1865c31c85df --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_mappings/helpers.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { fetchMappings } from './helpers'; +import { mockMappingsResponse } from '../mock/mappings_response/mock_mappings_response'; + +describe('helpers', () => { + let originalFetch: typeof global['fetch']; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('fetchMappings', () => { + test('it returns the expected mappings', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockMappingsResponse), + }); + global.fetch = mockFetch; + + const result = await fetchMappings({ + abortController: new AbortController(), + patternOrIndexName: 'auditbeat-custom-index-1', + }); + + expect(result).toEqual({ + 'auditbeat-custom-index-1': { + mappings: { + properties: { + '@timestamp': { type: 'date' }, + event: { properties: { category: { ignore_above: 1024, type: 'keyword' } } }, + host: { + properties: { + name: { + fields: { keyword: { ignore_above: 256, type: 'keyword' } }, + type: 'text', + }, + }, + }, + some: { + properties: { + field: { + fields: { keyword: { ignore_above: 256, type: 'keyword' } }, + type: 'text', + }, + }, + }, + source: { + properties: { + ip: { fields: { keyword: { ignore_above: 256, type: 'keyword' } }, type: 'text' }, + port: { type: 'long' }, + }, + }, + }, + }, + }, + }); + }); + + test('it throws the expected error when fetch fails', async () => { + const error = 'simulated error'; + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + statusText: error, + }); + + global.fetch = mockFetch; + + await expect( + fetchMappings({ + abortController: new AbortController(), + patternOrIndexName: 'auditbeat-custom-index-1', + }) + ).rejects.toThrowError( + 'Error loading mappings for auditbeat-custom-index-1: simulated error' + ); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.test.ts new file mode 100644 index 0000000000000..f25903adff823 --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.test.ts @@ -0,0 +1,511 @@ +/* + * 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 numeral from '@elastic/numeral'; + +import { + getTotalDocsCount, + getTotalIncompatible, + getTotalIndices, + getTotalIndicesChecked, + onPatternRollupUpdated, + updateResultOnCheckCompleted, +} from './helpers'; +import { auditbeatWithAllResults } from '../mock/pattern_rollup/mock_auditbeat_pattern_rollup'; +import { + mockPacketbeatPatternRollup, + packetbeatNoResults, + packetbeatWithSomeErrors, +} from '../mock/pattern_rollup/mock_packetbeat_pattern_rollup'; +import { PatternRollup } from '../types'; +import { EMPTY_STAT } from '../helpers'; +import { IndicesStatsIndicesStats } from '@elastic/elasticsearch/lib/api/types'; +import { mockPartitionedFieldMetadata } from '../mock/partitioned_field_metadata/mock_partitioned_field_metadata'; + +const defaultBytesFormat = '0,0.[0]b'; +const formatBytes = (value: number | undefined) => + value != null ? numeral(value).format(defaultBytesFormat) : EMPTY_STAT; + +const defaultNumberFormat = '0,0.[000]'; +const formatNumber = (value: number | undefined) => + value != null ? numeral(value).format(defaultNumberFormat) : EMPTY_STAT; + +const patternRollups: Record = { + 'auditbeat-*': auditbeatWithAllResults, // indices: 3 + 'packetbeat-*': mockPacketbeatPatternRollup, // indices: 2 +}; + +describe('helpers', () => { + let originalFetch: typeof global['fetch']; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('getTotalIndices', () => { + test('it returns the expected total when ALL `PatternRollup`s have an `indices`', () => { + expect(getTotalIndices(patternRollups)).toEqual(5); + }); + + test('it returns undefined when only SOME of the `PatternRollup`s have an `indices`', () => { + const someIndicesAreUndefined: Record = { + 'auditbeat-*': { + ...auditbeatWithAllResults, + indices: undefined, // <-- + }, + 'packetbeat-*': mockPacketbeatPatternRollup, // indices: 2 + }; + + expect(getTotalIndices(someIndicesAreUndefined)).toBeUndefined(); + }); + }); + + describe('getTotalDocsCount', () => { + test('it returns the expected total when ALL `PatternRollup`s have a `docsCount`', () => { + expect(getTotalDocsCount(patternRollups)).toEqual( + Number(auditbeatWithAllResults.docsCount) + Number(mockPacketbeatPatternRollup.docsCount) + ); + }); + + test('it returns undefined when only SOME of the `PatternRollup`s have a `docsCount`', () => { + const someIndicesAreUndefined: Record = { + 'auditbeat-*': { + ...auditbeatWithAllResults, + docsCount: undefined, // <-- + }, + 'packetbeat-*': mockPacketbeatPatternRollup, + }; + + expect(getTotalDocsCount(someIndicesAreUndefined)).toBeUndefined(); + }); + }); + + describe('getTotalIncompatible', () => { + test('it returns the expected total when ALL `PatternRollup`s have `results`', () => { + expect(getTotalIncompatible(patternRollups)).toEqual(4); + }); + + test('it returns the expected total when only SOME of the `PatternRollup`s have `results`', () => { + const someResultsAreUndefined: Record = { + 'auditbeat-*': auditbeatWithAllResults, + 'packetbeat-*': packetbeatNoResults, // <-- results is undefined + }; + + expect(getTotalIncompatible(someResultsAreUndefined)).toEqual(4); + }); + + test('it returns undefined when NONE of the `PatternRollup`s have `results`', () => { + const someResultsAreUndefined: Record = { + 'packetbeat-*': packetbeatNoResults, // <-- results is undefined + }; + + expect(getTotalIncompatible(someResultsAreUndefined)).toBeUndefined(); + }); + }); + + describe('getTotalIndicesChecked', () => { + test('it returns the expected total', () => { + expect(getTotalIndicesChecked(patternRollups)).toEqual(3); + }); + + test('it returns the expected total when errors have occurred', () => { + const someErrors: Record = { + 'auditbeat-*': auditbeatWithAllResults, // indices: 3 + 'packetbeat-*': packetbeatWithSomeErrors, // <-- indices: 2, but one has errors + }; + + expect(getTotalIndicesChecked(someErrors)).toEqual(4); + }); + }); + + describe('onPatternRollupUpdated', () => { + test('it returns a new collection with the updated rollup', () => { + const before: Record = { + 'auditbeat-*': auditbeatWithAllResults, + }; + + expect( + onPatternRollupUpdated({ + patternRollup: mockPacketbeatPatternRollup, + patternRollups: before, + }) + ).toEqual(patternRollups); + }); + }); + + describe('updateResultOnCheckCompleted', () => { + const packetbeatStats861: IndicesStatsIndicesStats = + mockPacketbeatPatternRollup.stats != null + ? mockPacketbeatPatternRollup.stats['.ds-packetbeat-8.6.1-2023.02.04-000001'] + : {}; + const packetbeatStats853: IndicesStatsIndicesStats = + mockPacketbeatPatternRollup.stats != null + ? mockPacketbeatPatternRollup.stats['.ds-packetbeat-8.5.3-2023.02.04-000001'] + : {}; + + test('it returns the updated rollups', () => { + expect( + updateResultOnCheckCompleted({ + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'packetbeat-*', + patternRollups: { + 'packetbeat-*': mockPacketbeatPatternRollup, + }, + }) + ).toEqual({ + 'packetbeat-*': { + docsCount: 3258632, + error: null, + ilmExplain: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536751379, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + '.ds-packetbeat-8.5.3-2023.02.04-000001': { + index: '.ds-packetbeat-8.5.3-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536774084, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536774084, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536774416, + action: 'rollover', + action_time_millis: 1675536774416, + step: 'check-rollover-ready', + step_time_millis: 1675536774416, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + }, + ilmExplainPhaseCounts: { + hot: 2, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 2, + pattern: 'packetbeat-*', + results: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + docsCount: 1628343, + error: null, + ilmPhase: 'hot', + incompatible: 3, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + markdownComments: [ + '### .ds-packetbeat-8.6.1-2023.02.04-000001\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | .ds-packetbeat-8.6.1-2023.02.04-000001 | 1,628,343 (50.0%) | 3 | `hot` | 697.7MB |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ], + pattern: 'packetbeat-*', + }, + }, + sizeInBytes: 1464758182, + stats: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': packetbeatStats861, + '.ds-packetbeat-8.5.3-2023.02.04-000001': packetbeatStats853, + }, + }, + }); + }); + + test('it returns the expected results when `patternRollup` does NOT have a `docsCount`', () => { + const noDocsCount = { + ...mockPacketbeatPatternRollup, + docsCount: undefined, // <-- + }; + + expect( + updateResultOnCheckCompleted({ + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'packetbeat-*', + patternRollups: { + 'packetbeat-*': noDocsCount, + }, + }) + ).toEqual({ + 'packetbeat-*': { + docsCount: undefined, // <-- + error: null, + ilmExplain: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536751379, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + '.ds-packetbeat-8.5.3-2023.02.04-000001': { + index: '.ds-packetbeat-8.5.3-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536774084, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536774084, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536774416, + action: 'rollover', + action_time_millis: 1675536774416, + step: 'check-rollover-ready', + step_time_millis: 1675536774416, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + }, + ilmExplainPhaseCounts: { + hot: 2, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 2, + pattern: 'packetbeat-*', + results: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + docsCount: 1628343, + error: null, + ilmPhase: 'hot', + incompatible: 3, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + markdownComments: [ + '### .ds-packetbeat-8.6.1-2023.02.04-000001\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | .ds-packetbeat-8.6.1-2023.02.04-000001 | 1,628,343 () | 3 | `hot` | 697.7MB |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ], + pattern: 'packetbeat-*', + }, + }, + sizeInBytes: 1464758182, + stats: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': packetbeatStats861, + '.ds-packetbeat-8.5.3-2023.02.04-000001': packetbeatStats853, + }, + }, + }); + }); + + test('it returns the expected results when `partitionedFieldMetadata` is null', () => { + expect( + updateResultOnCheckCompleted({ + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + partitionedFieldMetadata: null, // <-- + pattern: 'packetbeat-*', + patternRollups: { + 'packetbeat-*': mockPacketbeatPatternRollup, + }, + }) + ).toEqual({ + 'packetbeat-*': { + docsCount: 3258632, + error: null, + ilmExplain: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + index: '.ds-packetbeat-8.6.1-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536751379, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536751379, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536751809, + action: 'rollover', + action_time_millis: 1675536751809, + step: 'check-rollover-ready', + step_time_millis: 1675536751809, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + '.ds-packetbeat-8.5.3-2023.02.04-000001': { + index: '.ds-packetbeat-8.5.3-2023.02.04-000001', + managed: true, + policy: 'packetbeat', + index_creation_date_millis: 1675536774084, + time_since_index_creation: '25.26d', + lifecycle_date_millis: 1675536774084, + age: '25.26d', + phase: 'hot', + phase_time_millis: 1675536774416, + action: 'rollover', + action_time_millis: 1675536774416, + step: 'check-rollover-ready', + step_time_millis: 1675536774416, + phase_execution: { + policy: 'packetbeat', + version: 1, + modified_date_in_millis: 1675536751205, + }, + }, + }, + ilmExplainPhaseCounts: { + hot: 2, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 2, + pattern: 'packetbeat-*', + results: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + docsCount: 1628343, + error: null, + ilmPhase: 'hot', + incompatible: undefined, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + markdownComments: [], + pattern: 'packetbeat-*', + }, + }, + sizeInBytes: 1464758182, + stats: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': packetbeatStats861, + '.ds-packetbeat-8.5.3-2023.02.04-000001': packetbeatStats853, + }, + }, + }); + }); + + test('it returns the updated rollups when there is no `partitionedFieldMetadata`', () => { + const noIlmExplain = { + ...mockPacketbeatPatternRollup, + ilmExplain: null, + }; + + expect( + updateResultOnCheckCompleted({ + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'packetbeat-*', + patternRollups: { + 'packetbeat-*': noIlmExplain, + }, + }) + ).toEqual({ + 'packetbeat-*': { + docsCount: 3258632, + error: null, + ilmExplain: null, + ilmExplainPhaseCounts: { + hot: 2, + warm: 0, + cold: 0, + frozen: 0, + unmanaged: 0, + }, + indices: 2, + pattern: 'packetbeat-*', + results: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': { + docsCount: 1628343, + error: null, + ilmPhase: undefined, + incompatible: 3, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + markdownComments: [ + '### .ds-packetbeat-8.6.1-2023.02.04-000001\n', + '| Result | Index | Docs | Incompatible fields | ILM Phase | Size |\n|--------|-------|------|---------------------|-----------|------|\n| ❌ | .ds-packetbeat-8.6.1-2023.02.04-000001 | 1,628,343 (50.0%) | 3 | -- | 697.7MB |\n\n', + '### **Incompatible fields** `3` **Custom fields** `4` **ECS compliant fields** `2` **All fields** `9`\n', + "#### 3 incompatible fields, 0 fields with mappings in the same family\n\nFields are incompatible with ECS when index mappings, or the values of the fields in the index, don't conform to the Elastic Common Schema (ECS), version 8.6.1.\n\nIncompatible fields with mappings in the same family have exactly the same search behavior but may have different space usage or performance characteristics.\n\nWhen an incompatible field is not in the same family:\n❌ Detection engine rules referencing these fields may not match them correctly\n❌ Pages may not display some events or fields due to unexpected field mappings or values\n❌ Mappings or field values that don't comply with ECS are not supported\n", + '\n#### Incompatible field mappings - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS mapping type (expected) | Index mapping type (actual) | \n|-------|-----------------------------|-----------------------------|\n| host.name | `keyword` | `text` |\n| source.ip | `ip` | `text` |\n\n#### Incompatible field values - .ds-packetbeat-8.6.1-2023.02.04-000001\n\n\n| Field | ECS values (expected) | Document values (actual) | \n|-------|-----------------------|--------------------------|\n| event.category | `authentication`, `configuration`, `database`, `driver`, `email`, `file`, `host`, `iam`, `intrusion_detection`, `malware`, `network`, `package`, `process`, `registry`, `session`, `threat`, `vulnerability`, `web` | `an_invalid_category` (2), `theory` (1) |\n\n', + ], + pattern: 'packetbeat-*', + }, + }, + sizeInBytes: 1464758182, + stats: { + '.ds-packetbeat-8.6.1-2023.02.04-000001': packetbeatStats861, + '.ds-packetbeat-8.5.3-2023.02.04-000001': packetbeatStats853, + }, + }, + }); + }); + + test('it returns the unmodified rollups when `pattern` is not a member of `patternRollups`', () => { + const shouldNotBeModified: Record = { + 'packetbeat-*': mockPacketbeatPatternRollup, + }; + + expect( + updateResultOnCheckCompleted({ + error: null, + formatBytes, + formatNumber, + indexName: '.ds-packetbeat-8.6.1-2023.02.04-000001', + partitionedFieldMetadata: mockPartitionedFieldMetadata, + pattern: 'this-pattern-is-not-in-pattern-rollups', // <-- + patternRollups: shouldNotBeModified, + }) + ).toEqual(shouldNotBeModified); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.ts index 995ac8eac86c4..dbad0364904f5 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/helpers.ts @@ -8,7 +8,11 @@ import { getIndexDocsCountFromRollup } from '../data_quality_panel/data_quality_summary/summary_actions/check_all/helpers'; import { getIlmPhase } from '../data_quality_panel/pattern/helpers'; import { getAllIncompatibleMarkdownComments } from '../data_quality_panel/tabs/incompatible_tab/helpers'; -import { getTotalPatternIncompatible, getTotalPatternIndicesChecked } from '../helpers'; +import { + getSizeInBytes, + getTotalPatternIncompatible, + getTotalPatternIndicesChecked, +} from '../helpers'; import type { IlmPhase, PartitionedFieldMetadata, PatternRollup } from '../types'; export const getTotalIndices = ( @@ -19,7 +23,7 @@ export const getTotalIndices = ( // only return the total when all `PatternRollup`s have a `indices`: return allRollupsHaveIndices - ? allRollups.reduce((acc, { indices }) => acc + (indices ?? 0), 0) + ? allRollups.reduce((acc, { indices }) => acc + Number(indices), 0) : undefined; }; @@ -31,7 +35,21 @@ export const getTotalDocsCount = ( // only return the total when all `PatternRollup`s have a `docsCount`: return allRollupsHaveDocsCount - ? allRollups.reduce((acc, { docsCount }) => acc + (docsCount ?? 0), 0) + ? allRollups.reduce((acc, { docsCount }) => acc + Number(docsCount), 0) + : undefined; +}; + +export const getTotalSizeInBytes = ( + patternRollups: Record +): number | undefined => { + const allRollups = Object.values(patternRollups); + const allRollupsHaveSizeInBytes = allRollups.every(({ sizeInBytes }) => + Number.isInteger(sizeInBytes) + ); + + // only return the total when all `PatternRollup`s have a `sizeInBytes`: + return allRollupsHaveSizeInBytes + ? allRollups.reduce((acc, { sizeInBytes }) => acc + Number(sizeInBytes), 0) : undefined; }; @@ -69,6 +87,7 @@ export const onPatternRollupUpdated = ({ export const updateResultOnCheckCompleted = ({ error, + formatBytes, formatNumber, indexName, partitionedFieldMetadata, @@ -76,6 +95,7 @@ export const updateResultOnCheckCompleted = ({ patternRollups, }: { error: string | null; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata | null; @@ -85,7 +105,7 @@ export const updateResultOnCheckCompleted = ({ const patternRollup: PatternRollup | undefined = patternRollups[pattern]; if (patternRollup != null) { - const ilmExplain = patternRollup.ilmExplain ?? null; + const ilmExplain = patternRollup.ilmExplain; const ilmPhase: IlmPhase | undefined = ilmExplain != null ? getIlmPhase(ilmExplain[indexName]) : undefined; @@ -97,15 +117,19 @@ export const updateResultOnCheckCompleted = ({ const patternDocsCount = patternRollup.docsCount ?? 0; + const sizeInBytes = getSizeInBytes({ indexName, stats: patternRollup.stats }); + const markdownComments = partitionedFieldMetadata != null ? getAllIncompatibleMarkdownComments({ docsCount, + formatBytes, formatNumber, ilmPhase, indexName, partitionedFieldMetadata, patternDocsCount, + sizeInBytes, }) : []; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/index.tsx b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/index.tsx index da5745deaa3cd..1976b5e150de3 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/index.tsx +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_results_rollup/index.tsx @@ -17,6 +17,7 @@ import { getTotalIncompatible, getTotalIndices, getTotalIndicesChecked, + getTotalSizeInBytes, onPatternRollupUpdated, updateResultOnCheckCompleted, } from './helpers'; @@ -31,6 +32,7 @@ interface UseResultsRollup { totalIncompatible: number | undefined; totalIndices: number | undefined; totalIndicesChecked: number | undefined; + totalSizeInBytes: number | undefined; updatePatternIndexNames: ({ indexNames, pattern, @@ -58,6 +60,7 @@ export const useResultsRollup = ({ ilmPhases, patterns }: Props): UseResultsRoll () => getTotalIndicesChecked(patternRollups), [patternRollups] ); + const totalSizeInBytes = useMemo(() => getTotalSizeInBytes(patternRollups), [patternRollups]); const updatePatternIndexNames = useCallback( ({ indexNames, pattern }: { indexNames: string[]; pattern: string }) => { @@ -72,12 +75,14 @@ export const useResultsRollup = ({ ilmPhases, patterns }: Props): UseResultsRoll const onCheckCompleted: OnCheckCompleted = useCallback( ({ error, + formatBytes, formatNumber, indexName, partitionedFieldMetadata, pattern, }: { error: string | null; + formatBytes: (value: number | undefined) => string; formatNumber: (value: number | undefined) => string; indexName: string; partitionedFieldMetadata: PartitionedFieldMetadata | null; @@ -86,6 +91,7 @@ export const useResultsRollup = ({ ilmPhases, patterns }: Props): UseResultsRoll setPatternRollups((current) => updateResultOnCheckCompleted({ error, + formatBytes, formatNumber, indexName, partitionedFieldMetadata, @@ -111,6 +117,7 @@ export const useResultsRollup = ({ ilmPhases, patterns }: Props): UseResultsRoll totalIncompatible, totalIndices, totalIndicesChecked, + totalSizeInBytes, updatePatternIndexNames, updatePatternRollup, }; diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts new file mode 100644 index 0000000000000..2f80ba5e2cc7a --- /dev/null +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.test.ts @@ -0,0 +1,503 @@ +/* + * 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 { omit } from 'lodash/fp'; + +import { + fetchUnallowedValues, + getUnallowedValueCount, + getUnallowedValues, + isBucket, +} from './helpers'; +import { mockUnallowedValuesResponse } from '../mock/unallowed_values/mock_unallowed_values'; +import { UnallowedValueRequestItem, UnallowedValueSearchResult } from '../types'; + +describe('helpers', () => { + let originalFetch: typeof global['fetch']; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('isBucket', () => { + test('it returns true when the bucket is valid', () => { + expect( + isBucket({ + key: 'stop', + doc_count: 2, + }) + ).toBe(true); + }); + + test('it returns false when just `key` is missing', () => { + expect( + isBucket({ + doc_count: 2, + }) + ).toBe(false); + }); + + test('it returns false when just `key` has an incorrect type', () => { + expect( + isBucket({ + key: 1234, // <-- should be a string + doc_count: 2, + }) + ).toBe(false); + }); + + test('it returns false when just `doc_count` is missing', () => { + expect( + isBucket({ + key: 'stop', + }) + ).toBe(false); + }); + + test('it returns false when just `doc_count` has an incorrect type', () => { + expect( + isBucket({ + key: 'stop', + doc_count: 'foo', // <-- should be a number + }) + ).toBe(false); + }); + + test('it returns false when both `key` and `doc_count` are missing', () => { + expect(isBucket({})).toBe(false); + }); + + test('it returns false when both `key` and `doc_count` have incorrect types', () => { + expect( + isBucket({ + key: 1234, // <-- should be a string + doc_count: 'foo', // <-- should be a number + }) + ).toBe(false); + }); + + test('it returns false when `maybeBucket` is undefined', () => { + expect(isBucket(undefined)).toBe(false); + }); + }); + + describe('getUnallowedValueCount', () => { + test('it returns the expected count', () => { + expect( + getUnallowedValueCount({ + key: 'stop', + doc_count: 2, + }) + ).toEqual({ count: 2, fieldName: 'stop' }); + }); + }); + + describe('getUnallowedValues', () => { + const requestItems: UnallowedValueRequestItem[] = [ + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.category', + allowedValues: [ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.kind', + allowedValues: [ + 'alert', + 'enrichment', + 'event', + 'metric', + 'state', + 'pipeline_error', + 'signal', + ], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.outcome', + allowedValues: ['failure', 'success', 'unknown'], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.type', + allowedValues: [ + 'access', + 'admin', + 'allowed', + 'change', + 'connection', + 'creation', + 'deletion', + 'denied', + 'end', + 'error', + 'group', + 'indicator', + 'info', + 'installation', + 'protocol', + 'start', + 'user', + ], + }, + ]; + + const searchResults: UnallowedValueSearchResult[] = [ + { + aggregations: { + 'event.category': { + buckets: [ + { + key: 'an_invalid_category', + doc_count: 2, + }, + { + key: 'theory', + doc_count: 1, + }, + ], + }, + }, + }, + { + aggregations: { + 'event.kind': { + buckets: [], + }, + }, + }, + { + aggregations: { + 'event.outcome': { + buckets: [], + }, + }, + }, + { + aggregations: { + 'event.type': { + buckets: [], + }, + }, + }, + ]; + + test('it returns the expected unallowed values', () => { + expect( + getUnallowedValues({ + requestItems, + searchResults, + }) + ).toEqual({ + 'event.category': [ + { count: 2, fieldName: 'an_invalid_category' }, + { count: 1, fieldName: 'theory' }, + ], + 'event.kind': [], + 'event.outcome': [], + 'event.type': [], + }); + }); + + test('it returns an empty index when `searchResults` is null', () => { + expect( + getUnallowedValues({ + requestItems, + searchResults: null, + }) + ).toEqual({}); + }); + + test('it returns an empty index when `searchResults` is not an array', () => { + expect( + getUnallowedValues({ + requestItems, + // @ts-expect-error + searchResults: 1234, + }) + ).toEqual({}); + }); + + test('it returns the expected results when `searchResults` does NOT have `aggregations`', () => { + const noAggregations: UnallowedValueSearchResult[] = searchResults.map((x) => + omit('aggregations', x) + ); + + expect( + getUnallowedValues({ + requestItems, + searchResults: noAggregations, + }) + ).toEqual({ + 'event.category': [], + 'event.kind': [], + 'event.outcome': [], + 'event.type': [], + }); + }); + + test('it returns the expected unallowed values when SOME buckets are invalid', () => { + const someInvalid: UnallowedValueSearchResult[] = [ + { + aggregations: { + 'event.category': { + buckets: [ + { + key: 'foo', + // @ts-expect-error + doc_count: 'this-is-an-invalid-bucket', // <-- invalid type, should be number + }, + { + key: 'bar', + doc_count: 1, + }, + ], + }, + }, + }, + { + aggregations: { + 'event.kind': { + buckets: [], + }, + }, + }, + { + aggregations: { + 'event.outcome': { + buckets: [], + }, + }, + }, + { + aggregations: { + 'event.type': { + buckets: [], + }, + }, + }, + ]; + + expect( + getUnallowedValues({ + requestItems, + searchResults: someInvalid, + }) + ).toEqual({ + 'event.category': [{ count: 1, fieldName: 'bar' }], + 'event.kind': [], + 'event.outcome': [], + 'event.type': [], + }); + }); + }); + + describe('fetchUnallowedValues', () => { + const requestItems: UnallowedValueRequestItem[] = [ + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.category', + allowedValues: [ + 'authentication', + 'configuration', + 'database', + 'driver', + 'email', + 'file', + 'host', + 'iam', + 'intrusion_detection', + 'malware', + 'network', + 'package', + 'process', + 'registry', + 'session', + 'threat', + 'vulnerability', + 'web', + ], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.kind', + allowedValues: [ + 'alert', + 'enrichment', + 'event', + 'metric', + 'state', + 'pipeline_error', + 'signal', + ], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.outcome', + allowedValues: ['failure', 'success', 'unknown'], + }, + { + indexName: 'auditbeat-custom-index-1', + indexFieldName: 'event.type', + allowedValues: [ + 'access', + 'admin', + 'allowed', + 'change', + 'connection', + 'creation', + 'deletion', + 'denied', + 'end', + 'error', + 'group', + 'indicator', + 'info', + 'installation', + 'protocol', + 'start', + 'user', + ], + }, + ]; + + test('it includes the expected content in the `fetch` request', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockUnallowedValuesResponse), + }); + global.fetch = mockFetch; + const abortController = new AbortController(); + + await fetchUnallowedValues({ + abortController, + indexName: 'auditbeat-custom-index-1', + requestItems, + }); + + expect(mockFetch).toBeCalledWith( + '/internal/ecs_data_quality_dashboard/unallowed_field_values', + { + body: JSON.stringify(requestItems), + headers: { 'Content-Type': 'application/json', 'kbn-xsrf': 'xsrf' }, + method: 'POST', + signal: abortController.signal, + } + ); + }); + + test('it returns the expected unallowed values', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockUnallowedValuesResponse), + }); + global.fetch = mockFetch; + + const result = await fetchUnallowedValues({ + abortController: new AbortController(), + indexName: 'auditbeat-custom-index-1', + requestItems, + }); + + expect(result).toEqual([ + { + _shards: { failed: 0, skipped: 0, successful: 1, total: 1 }, + aggregations: { + 'event.category': { + buckets: [ + { doc_count: 2, key: 'an_invalid_category' }, + { doc_count: 1, key: 'theory' }, + ], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + }, + hits: { hits: [], max_score: null, total: { relation: 'eq', value: 3 } }, + status: 200, + timed_out: false, + took: 1, + }, + { + _shards: { failed: 0, skipped: 0, successful: 1, total: 1 }, + aggregations: { + 'event.kind': { buckets: [], doc_count_error_upper_bound: 0, sum_other_doc_count: 0 }, + }, + hits: { hits: [], max_score: null, total: { relation: 'eq', value: 4 } }, + status: 200, + timed_out: false, + took: 0, + }, + { + _shards: { failed: 0, skipped: 0, successful: 1, total: 1 }, + aggregations: { + 'event.outcome': { + buckets: [], + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + }, + }, + hits: { hits: [], max_score: null, total: { relation: 'eq', value: 4 } }, + status: 200, + timed_out: false, + took: 0, + }, + { + _shards: { failed: 0, skipped: 0, successful: 1, total: 1 }, + aggregations: { + 'event.type': { buckets: [], doc_count_error_upper_bound: 0, sum_other_doc_count: 0 }, + }, + hits: { hits: [], max_score: null, total: { relation: 'eq', value: 4 } }, + status: 200, + timed_out: false, + took: 0, + }, + ]); + }); + + test('it throws the expected error when fetch fails', async () => { + const error = 'simulated error'; + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + statusText: error, + }); + global.fetch = mockFetch; + + await expect( + fetchUnallowedValues({ + abortController: new AbortController(), + indexName: 'auditbeat-custom-index-1', + requestItems, + }) + ).rejects.toThrowError( + 'Error loading unallowed values for index auditbeat-custom-index-1: simulated error' + ); + }); + }); +}); diff --git a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.ts b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.ts index 1fa8991ce9264..e1ee93b72b283 100644 --- a/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.ts +++ b/x-pack/packages/kbn-ecs-data-quality-dashboard/impl/data_quality/use_unallowed_values/helpers.ts @@ -16,6 +16,7 @@ import type { const UNALLOWED_VALUES_API_ROUTE = '/internal/ecs_data_quality_dashboard/unallowed_field_values'; export const isBucket = (maybeBucket: unknown): maybeBucket is Bucket => + maybeBucket != null && typeof (maybeBucket as Bucket).key === 'string' && typeof (maybeBucket as Bucket).doc_count === 'number'; diff --git a/x-pack/plugins/security_solution/public/overview/links.ts b/x-pack/plugins/security_solution/public/overview/links.ts index 2d75eee57bece..07cc2a491cf02 100644 --- a/x-pack/plugins/security_solution/public/overview/links.ts +++ b/x-pack/plugins/security_solution/public/overview/links.ts @@ -103,7 +103,6 @@ export const ecsDataQualityDashboardLinks: LinkItem = { ), path: DATA_QUALITY_PATH, capabilities: [`${SERVER_APP_ID}.show`], - isBeta: true, globalSearchKeywords: [ i18n.translate('xpack.securitySolution.appLinks.ecsDataQualityDashboard', { defaultMessage: 'Data Quality', diff --git a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx index 35a991e6ae7ad..4f7df91715247 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/data_quality.tsx @@ -33,11 +33,10 @@ import { SecurityPageName } from '../../app/types'; import { getGroupByFieldsOnClick } from '../../common/components/alerts_treemap/lib/helpers'; import { useTheme } from '../../common/components/charts/common'; import { HeaderPage } from '../../common/components/header_page'; -import type { BadgeOptions } from '../../common/components/header_page/types'; import { LandingPageComponent } from '../../common/components/landing_page'; import { useLocalStorage } from '../../common/components/local_storage'; import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; -import { DEFAULT_NUMBER_FORMAT } from '../../../common/constants'; +import { DEFAULT_BYTES_FORMAT, DEFAULT_NUMBER_FORMAT } from '../../../common/constants'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useGetUserCasesPermissions, @@ -51,11 +50,6 @@ import * as i18n from './translations'; const LOCAL_STORAGE_KEY = 'dataQualityDashboardLastChecked'; -const badgeOptions: BadgeOptions = { - beta: true, - text: i18n.BETA, -}; - const comboBoxStyle: React.CSSProperties = { width: '322px', }; @@ -141,6 +135,7 @@ const DataQualityComponent: React.FC = () => { }, [toasts] ); + const [defaultBytesFormat] = useUiSetting$(DEFAULT_BYTES_FORMAT); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const labelInputId = useGeneratedHtmlId({ prefix: 'labelInput' }); const [selectedOptions, setSelectedOptions] = useState(defaultOptions); @@ -210,11 +205,7 @@ const DataQualityComponent: React.FC = () => { {indicesExist ? ( <> - + {