diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/Chart.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Chart/Chart.interface.ts index fddadca537d..9dd77c183ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Chart/Chart.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/Chart.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import { ColumnProfile } from 'generated/entity/data/table'; import { MetricChartType } from '../ProfilerDashboard/profilerDashboard.interface'; export interface CustomBarChartProps { @@ -18,3 +19,10 @@ export interface CustomBarChartProps { name: string; tickFormatter?: string; } + +export interface DataDistributionHistogramProps { + data: { + firstDayData?: ColumnProfile; + currentDayData?: ColumnProfile; + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.component.tsx new file mode 100644 index 00000000000..b05f514b56d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.component.tsx @@ -0,0 +1,132 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Col, Row, Tag } from 'antd'; +import ErrorPlaceHolder from 'components/common/error-with-placeholder/ErrorPlaceHolder'; +import { GRAPH_BACKGROUND_COLOR } from 'constants/constants'; +import { DEFAULT_HISTOGRAM_DATA } from 'constants/profiler.constant'; +import { HistogramClass } from 'generated/entity/data/table'; +import { isUndefined, map } from 'lodash'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { axisTickFormatter, tooltipFormatter } from 'utils/ChartUtils'; +import { getFormattedDateFromSeconds } from 'utils/TimeUtils'; +import { DataDistributionHistogramProps } from './Chart.interface'; + +const DataDistributionHistogram = ({ + data, +}: DataDistributionHistogramProps) => { + const { t } = useTranslation(); + const showSingleGraph = + isUndefined(data.firstDayData?.histogram) || + isUndefined(data.currentDayData?.histogram); + + if ( + isUndefined(data.firstDayData?.histogram) && + isUndefined(data.currentDayData?.histogram) + ) { + return ( + + + +

{t('message.no-data-available')}

+
+ +
+ ); + } + + return ( + + {map(data, (columnProfile, key) => { + if (isUndefined(columnProfile?.histogram)) { + return; + } + + const histogramData = + (columnProfile?.histogram as HistogramClass) || + DEFAULT_HISTOGRAM_DATA; + + const graphData = histogramData.frequencies?.map((frequency, i) => ({ + name: histogramData?.boundaries?.[i], + frequency, + })); + + const graphDate = getFormattedDateFromSeconds( + columnProfile?.timestamp || 0, + 'dd/MMM' + ); + + return ( + + + + {graphDate} + + + {`${t('label.skew')}: ${ + columnProfile?.nonParametricSkew || '--' + }`} + + + + + + + axisTickFormatter(props)} + /> + + tooltipFormatter(value)} + /> + + + + + + + ); + })} + + ); +}; + +export default DataDistributionHistogram; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.test.tsx new file mode 100644 index 00000000000..41ba7eb52ed --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Chart/DataDistributionHistogram.test.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { queryByAttribute, render, screen } from '@testing-library/react'; +import React from 'react'; +import DataDistributionHistogram from './DataDistributionHistogram.component'; + +const MOCK_HISTOGRAM_DATA = [ + { + name: 'shop_id', + timestamp: 1678375427, + valuesCount: 14567.0, + nullCount: 0.0, + nullProportion: 0.0, + uniqueCount: 14567.0, + uniqueProportion: 1.0, + distinctCount: 14509.0, + distinctProportion: 1.0, + min: 1.0, + max: 587.0, + mean: 45.0, + sum: 1367.0, + stddev: 35.0, + median: 7654.0, + firstQuartile: 7.4, + thirdQuartile: 8766.5, + interQuartileRange: 8002.1, + nonParametricSkew: -0.567, + histogram: { + boundaries: [ + '5.00 to 100.00', + '100.00 to 200.00', + '200.00 to 300.00', + '300.00 and up', + ], + frequencies: [101, 235, 123, 98], + }, + }, + { + name: 'shop_id', + timestamp: 1678202627, + valuesCount: 10256.0, + nullCount: 0.0, + nullProportion: 0.0, + uniqueCount: 10098.0, + uniqueProportion: 0.91, + distinctCount: 10256.0, + distinctProportion: 1.0, + min: 1.0, + max: 542.0, + mean: 45.0, + sum: 1367.0, + stddev: 35.0, + median: 7344.0, + firstQuartile: 7.4, + thirdQuartile: 8005.5, + interQuartileRange: 8069.1, + nonParametricSkew: -0.567, + histogram: { + boundaries: [ + '5.00 to 100.00', + '100.00 to 200.00', + '200.00 to 300.00', + '300.00 and up', + ], + frequencies: [56, 62, 66, 99], + }, + }, +]; + +const COLUMN_PROFILER = { + name: 'shop_id', + timestamp: 1678169698, + valuesCount: 10256.0, + nullCount: 0.0, + nullProportion: 0.0, + uniqueCount: 10098.0, + uniqueProportion: 0.91, + distinctCount: 10256.0, + distinctProportion: 1.0, + min: 1.0, + max: 542.0, + mean: 45.0, + sum: 1367.0, + stddev: 35.0, + median: 7344.0, +}; + +jest.mock('components/common/error-with-placeholder/ErrorPlaceHolder', () => { + return jest.fn().mockImplementation(({ children }) =>
{children}
); +}); + +describe('DataDistributionHistogram component test', () => { + it('Component should render', async () => { + const { container } = render( + + ); + const skewTags = await screen.findAllByTestId('skew-tag'); + const date = await screen.findAllByTestId('date'); + + expect(await screen.findByTestId('chart-container')).toBeInTheDocument(); + expect( + queryByAttribute('id', container, 'firstDayData-histogram') + ).toBeInTheDocument(); + expect( + queryByAttribute('id', container, 'currentDayData-histogram') + ).toBeInTheDocument(); + expect(skewTags).toHaveLength(2); + expect(date).toHaveLength(2); + }); + + it('Render one graph if histogram data is available in only one profile data', async () => { + const { container } = render( + + ); + const skewTags = await screen.findAllByTestId('skew-tag'); + const date = await screen.findAllByTestId('date'); + + expect(await screen.findByTestId('chart-container')).toBeInTheDocument(); + expect( + queryByAttribute('id', container, 'firstDayData-histogram') + ).not.toBeInTheDocument(); + expect( + queryByAttribute('id', container, 'currentDayData-histogram') + ).toBeInTheDocument(); + expect(skewTags).toHaveLength(1); + expect(date).toHaveLength(1); + }); + + it('No data placeholder should render when firstDay & currentDay data is undefined', async () => { + render( + + ); + + expect( + await screen.findByText('message.no-data-available') + ).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.test.tsx index e8122661346..6a34f80f4e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.test.tsx @@ -45,6 +45,11 @@ jest.mock('./ProfilerSummaryCard', () => { jest.mock('./ProfilerDetailsCard', () => { return jest.fn().mockImplementation(() =>
ProfilerDetailsCard
); }); +jest.mock('components/Chart/DataDistributionHistogram.component', () => { + return jest + .fn() + .mockImplementation(() =>
DataDistributionHistogram
); +}); jest.mock('react-i18next', () => ({ // this mock makes sure any components using the translate hook can use it without a warning being shown @@ -65,6 +70,7 @@ describe('Test ProfilerTab component', () => { const pageContainer = await screen.findByTestId('profiler-tab-container'); const description = await screen.findByTestId('description'); + const histogram = await screen.findByTestId('histogram-metrics'); const dataTypeContainer = await screen.findByTestId('data-type-container'); const ProfilerSummaryCards = await screen.findAllByText( 'ProfilerSummaryCard' @@ -76,8 +82,9 @@ describe('Test ProfilerTab component', () => { expect(pageContainer).toBeInTheDocument(); expect(description).toBeInTheDocument(); expect(dataTypeContainer).toBeInTheDocument(); + expect(histogram).toBeInTheDocument(); expect(ProfilerSummaryCards).toHaveLength(2); - expect(ProfilerDetailsCards).toHaveLength(4); + expect(ProfilerDetailsCards).toHaveLength(5); }); it('ProfilerTab component should render properly with empty data', async () => { @@ -97,6 +104,7 @@ describe('Test ProfilerTab component', () => { const pageContainer = await screen.findByTestId('profiler-tab-container'); const description = await screen.findByTestId('description'); const dataTypeContainer = await screen.findByTestId('data-type-container'); + const histogram = await screen.findByTestId('histogram-metrics'); const ProfilerSummaryCards = await screen.findAllByText( 'ProfilerSummaryCard' ); @@ -107,8 +115,9 @@ describe('Test ProfilerTab component', () => { expect(pageContainer).toBeInTheDocument(); expect(description).toBeInTheDocument(); expect(dataTypeContainer).toBeInTheDocument(); + expect(histogram).toBeInTheDocument(); expect(ProfilerSummaryCards).toHaveLength(2); - expect(ProfilerDetailsCards).toHaveLength(4); + expect(ProfilerDetailsCards).toHaveLength(5); }); it('ProfilerTab component should render properly even if getListTestCase API fails', async () => { @@ -123,6 +132,7 @@ describe('Test ProfilerTab component', () => { const pageContainer = await screen.findByTestId('profiler-tab-container'); const description = await screen.findByTestId('description'); + const histogram = await screen.findByTestId('histogram-metrics'); const dataTypeContainer = await screen.findByTestId('data-type-container'); const ProfilerSummaryCards = await screen.findAllByText( 'ProfilerSummaryCard' @@ -134,7 +144,8 @@ describe('Test ProfilerTab component', () => { expect(pageContainer).toBeInTheDocument(); expect(description).toBeInTheDocument(); expect(dataTypeContainer).toBeInTheDocument(); + expect(histogram).toBeInTheDocument(); expect(ProfilerSummaryCards).toHaveLength(2); - expect(ProfilerDetailsCards).toHaveLength(4); + expect(ProfilerDetailsCards).toHaveLength(5); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.tsx index b877824f23b..b87a0f84c80 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/ProfilerTab.tsx @@ -13,7 +13,8 @@ import { Card, Col, Row, Statistic, Typography } from 'antd'; import { AxiosError } from 'axios'; -import { sortBy } from 'lodash'; +import DataDistributionHistogram from 'components/Chart/DataDistributionHistogram.component'; +import { first, last, sortBy } from 'lodash'; import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; @@ -23,6 +24,7 @@ import { INITIAL_COUNT_METRIC_VALUE, INITIAL_MATH_METRIC_VALUE, INITIAL_PROPORTION_METRIC_VALUE, + INITIAL_QUARTILE_METRIC_VALUE, INITIAL_SUM_METRIC_VALUE, INITIAL_TEST_RESULT_SUMMARY, } from '../../../constants/profiler.constant'; @@ -58,6 +60,9 @@ const ProfilerTab: React.FC = ({ const [sumMetrics, setSumMetrics] = useState( INITIAL_SUM_METRIC_VALUE ); + const [quartileMetrics, setQuartileMetrics] = useState( + INITIAL_QUARTILE_METRIC_VALUE + ); const [tableTests, setTableTests] = useState({ tests: [], results: INITIAL_TEST_RESULT_SUMMARY, @@ -105,12 +110,20 @@ const ProfilerTab: React.FC = ({ ]; }, [tableTests]); + const { firstDay, currentDay } = useMemo(() => { + return { + firstDay: last(profilerData), + currentDay: first(profilerData), + }; + }, [profilerData]); + const createMetricsChartData = () => { const updateProfilerData = sortBy(profilerData, 'timestamp'); const countMetricData: MetricChartType['data'] = []; const proportionMetricData: MetricChartType['data'] = []; const mathMetricData: MetricChartType['data'] = []; const sumMetricData: MetricChartType['data'] = []; + const quartileMetricData: MetricChartType['data'] = []; updateProfilerData.forEach((col) => { const x = getFormattedDateFromSeconds(col.timestamp); @@ -135,7 +148,6 @@ const ProfilerTab: React.FC = ({ max: (col.max as number) || 0, min: (col.min as number) || 0, mean: col.mean || 0, - median: col.median || 0, }); proportionMetricData.push({ @@ -145,6 +157,15 @@ const ProfilerTab: React.FC = ({ nullProportion: Math.round((col.nullProportion || 0) * 100), uniqueProportion: Math.round((col.uniqueProportion || 0) * 100), }); + + quartileMetricData.push({ + name: x, + timestamp: col.timestamp || 0, + firstQuartile: col.firstQuartile || 0, + thirdQuartile: col.thirdQuartile || 0, + interQuartileRange: col.interQuartileRange || 0, + median: col.median || 0, + }); }); const countMetricInfo = countMetrics.information.map((item) => ({ @@ -171,6 +192,11 @@ const ProfilerTab: React.FC = ({ ...item, latestValue: sumMetricData[sumMetricData.length - 1]?.[item.dataKey] || 0, })); + const quartileMetricInfo = quartileMetrics.information.map((item) => ({ + ...item, + latestValue: + quartileMetricData[quartileMetricData.length - 1]?.[item.dataKey] || 0, + })); setCountMetrics((pre) => ({ ...pre, @@ -192,6 +218,11 @@ const ProfilerTab: React.FC = ({ information: sumMetricInfo, data: sumMetricData, })); + setQuartileMetrics((pre) => ({ + ...pre, + information: quartileMetricInfo, + data: quartileMetricData, + })); }; const fetchAllTests = async () => { @@ -227,7 +258,10 @@ const ProfilerTab: React.FC = ({ }, []); return ( - + @@ -294,6 +328,30 @@ const ProfilerTab: React.FC = ({ + + + + + + + + + {t('label.data-distribution')} + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/profiler.constant.ts b/openmetadata-ui/src/main/resources/ui/src/constants/profiler.constant.ts index d164b344979..86e26dfff85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/profiler.constant.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/profiler.constant.ts @@ -13,6 +13,7 @@ import { t } from 'i18next'; import { StepperStepType } from 'Models'; +import i18n from 'utils/i18next/LocalUtil'; import { CSMode } from '../enums/codemirror.enum'; import { DMLOperationType } from '../generated/api/data/createTableProfile'; import { @@ -170,25 +171,20 @@ export const INITIAL_PROPORTION_METRIC_VALUE = { export const INITIAL_MATH_METRIC_VALUE = { information: [ - { - title: t('label.median'), - dataKey: 'median', - color: '#1890FF', - }, { title: t('label.max'), dataKey: 'max', - color: '#7147E8', + color: '#1890FF', }, { title: t('label.mean'), dataKey: 'mean', - color: '#008376', + color: '#7147E8', }, { title: t('label.min'), dataKey: 'min', - color: '#B02AAC', + color: '#008376', }, ], data: [], @@ -204,6 +200,31 @@ export const INITIAL_SUM_METRIC_VALUE = { ], data: [], }; +export const INITIAL_QUARTILE_METRIC_VALUE = { + information: [ + { + title: i18n.t('label.first-quartile'), + dataKey: 'firstQuartile', + color: '#1890FF', + }, + { + title: i18n.t('label.median'), + dataKey: 'median', + color: '#7147E8', + }, + { + title: i18n.t('label.inter-quartile-range'), + dataKey: 'interQuartileRange', + color: '#008376', + }, + { + title: i18n.t('label.third-quartile'), + dataKey: 'thirdQuartile', + color: '#B02AAC', + }, + ], + data: [], +}; export const INITIAL_ROW_METRIC_VALUE = { information: [ @@ -327,3 +348,8 @@ export const PROFILE_SAMPLE_OPTIONS = [ value: ProfileSampleType.Rows, }, ]; + +export const DEFAULT_HISTOGRAM_DATA = { + boundaries: [], + frequencies: [], +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 3876dcb4696..1c582fb4221 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -165,6 +165,7 @@ "data-asset-type": "Data Asset Type", "data-assets-report": "Data Assets Report", "data-assets-with-tier-plural": "Data Assets with Tiers", + "data-distribution": "Data Distribution", "data-entity": "Data {{entity}}", "data-insight": "Data Insight", "data-insight-active-user-summary": "Most Active Users", @@ -295,6 +296,7 @@ "filter-plural": "Filters", "first": "First", "first-lowercase": "first", + "first-quartile": "First Quartile", "flush-interval-secs": "Flush Interval (secs)", "follow": "Follow", "followed-lowercase": "followed", @@ -365,6 +367,7 @@ "install-service-connectors": "Install Service Connectors", "instance-lowercase": "instance", "integration-plural": "Integrations", + "inter-quartile-range": "Inter Quartile Range", "interval": "Interval", "interval-type": "Interval Type", "interval-unit": "Interval Unit", @@ -676,6 +679,7 @@ "show-deleted-team": "Show Deleted Team", "show-or-hide-advanced-config": "{{showAdv}} Advanced Config", "sign-in-with-sso": "Sign in with {{sso}}", + "skew": "Skew", "slack": "Slack", "soft-delete": "Soft Delete", "soft-lowercase": "soft", @@ -746,6 +750,7 @@ "testing-connection": "Testing Connection", "tests-summary": "Tests Summary", "text": "Text", + "third-quartile": "Third Quartile", "thread": "Thread", "three-dash-symbol": "---", "three-dots-symbol": "•••", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 5d4ad9994ba..5732cf4a5b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -165,6 +165,7 @@ "data-asset-type": "Type de Resources de Données", "data-assets-report": "Data Assets Report", "data-assets-with-tier-plural": "Data Assets with Tiers", + "data-distribution": "Data Distribution", "data-entity": "Data {{entity}}", "data-insight": "Data Insight", "data-insight-active-user-summary": "Utilisateurs les plus Actfis", @@ -295,6 +296,7 @@ "filter-plural": "Filters", "first": "First", "first-lowercase": "first", + "first-quartile": "First Quartile", "flush-interval-secs": "Flush Interval (secs)", "follow": "Follow", "followed-lowercase": "followed", @@ -365,6 +367,7 @@ "install-service-connectors": "Install Service Connectors", "instance-lowercase": "instance", "integration-plural": "Integrations", + "inter-quartile-range": "Inter Quartile Range", "interval": "Interval", "interval-type": "Type d'Interval", "interval-unit": "Unité d'Interval", @@ -676,6 +679,7 @@ "show-deleted-team": "Show Deleted Team", "show-or-hide-advanced-config": "{{showAdv}} Config Avancée", "sign-in-with-sso": "Sign in with {{sso}}", + "skew": "Skew", "slack": "Slack", "soft-delete": "Suppression Logique (Soft delete)", "soft-lowercase": "soft", @@ -746,6 +750,7 @@ "testing-connection": "Testing Connection", "tests-summary": "Tests Summary", "text": "Text", + "third-quartile": "Third Quartile", "thread": "Thread", "three-dash-symbol": "---", "three-dots-symbol": "•••", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index 53b4ea4f6d9..355a52445fd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -165,6 +165,7 @@ "data-asset-type": "数据资产类型", "data-assets-report": "数据资产报告", "data-assets-with-tier-plural": "分层的数据资产", + "data-distribution": "Data Distribution", "data-entity": "数据 {{entity}}", "data-insight": "数据洞察", "data-insight-active-user-summary": "最活跃用户", @@ -295,6 +296,7 @@ "filter-plural": "过滤器", "first": "First", "first-lowercase": "first", + "first-quartile": "First Quartile", "flush-interval-secs": "刷新间隔 (secs)", "follow": "Follow", "followed-lowercase": "被关注", @@ -365,6 +367,7 @@ "install-service-connectors": "Install Service Connectors", "instance-lowercase": "instance", "integration-plural": "Integrations", + "inter-quartile-range": "Inter Quartile Range", "interval": "间隔", "interval-type": "间隔类型", "interval-unit": "间隔单位", @@ -676,6 +679,7 @@ "show-deleted-team": "Show Deleted Team", "show-or-hide-advanced-config": "{{showAdv}} 高级配置", "sign-in-with-sso": "Sign in with {{sso}}", + "skew": "Skew", "slack": "Slack", "soft-delete": "软删除", "soft-lowercase": "soft", @@ -746,6 +750,7 @@ "testing-connection": "Testing Connection", "tests-summary": "Tests Summary", "text": "Text", + "third-quartile": "Third Quartile", "thread": "Thread", "three-dash-symbol": "---", "three-dots-symbol": "•••",