mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-12 08:52:38 +00:00
* #13445 Profiler Graph Should not Show a Point if value is None * addressing comment * fixed sonarcloud
This commit is contained in:
parent
d3ebe6fb4b
commit
2337e239a4
@ -16,6 +16,7 @@ import React from 'react';
|
|||||||
import { getStatisticsDisplayValue } from '../../../utils/CommonUtils';
|
import { getStatisticsDisplayValue } from '../../../utils/CommonUtils';
|
||||||
import { ProfilerLatestValueProps } from '../profilerDashboard.interface';
|
import { ProfilerLatestValueProps } from '../profilerDashboard.interface';
|
||||||
|
|
||||||
|
import { isUndefined } from 'lodash';
|
||||||
import '../profiler-dashboard.less';
|
import '../profiler-dashboard.less';
|
||||||
|
|
||||||
const ProfilerLatestValue = ({
|
const ProfilerLatestValue = ({
|
||||||
@ -23,6 +24,18 @@ const ProfilerLatestValue = ({
|
|||||||
tickFormatter,
|
tickFormatter,
|
||||||
stringValue = false,
|
stringValue = false,
|
||||||
}: ProfilerLatestValueProps) => {
|
}: ProfilerLatestValueProps) => {
|
||||||
|
const getLatestValue = (value?: number | string) => {
|
||||||
|
if (isUndefined(value)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tickFormatter || stringValue) {
|
||||||
|
return `${value}${tickFormatter ?? ''}`;
|
||||||
|
} else {
|
||||||
|
return getStatisticsDisplayValue(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space data-testid="data-summary-container" direction="vertical" size={16}>
|
<Space data-testid="data-summary-container" direction="vertical" size={16}>
|
||||||
{information.map((info) => (
|
{information.map((info) => (
|
||||||
@ -36,11 +49,7 @@ const ProfilerLatestValue = ({
|
|||||||
{info.title}
|
{info.title}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
}
|
}
|
||||||
value={
|
value={getLatestValue(info.latestValue)}
|
||||||
tickFormatter || stringValue
|
|
||||||
? `${info.latestValue}${tickFormatter ?? ''}`
|
|
||||||
: getStatisticsDisplayValue(info.latestValue)
|
|
||||||
}
|
|
||||||
valueStyle={{ color: info.color, fontSize: '18px', fontWeight: 700 }}
|
valueStyle={{ color: info.color, fontSize: '18px', fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -12,31 +12,27 @@
|
|||||||
*/
|
*/
|
||||||
import { Card, Col, Row, Typography } from 'antd';
|
import { Card, Col, Row, Typography } from 'antd';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { first, isString, last, sortBy } from 'lodash';
|
import { first, isString, last } from 'lodash';
|
||||||
import React, { FC, useEffect, useMemo, useState } from 'react';
|
import React, { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import DataDistributionHistogram from '../../../components/Chart/DataDistributionHistogram.component';
|
import DataDistributionHistogram from '../../../components/Chart/DataDistributionHistogram.component';
|
||||||
import ProfilerDetailsCard from '../../../components/ProfilerDashboard/component/ProfilerDetailsCard';
|
import ProfilerDetailsCard from '../../../components/ProfilerDashboard/component/ProfilerDetailsCard';
|
||||||
import { DateRangeObject } from '../../../components/ProfilerDashboard/component/TestSummary';
|
import { DateRangeObject } from '../../../components/ProfilerDashboard/component/TestSummary';
|
||||||
import { MetricChartType } from '../../../components/ProfilerDashboard/profilerDashboard.interface';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_RANGE_DATA,
|
DEFAULT_RANGE_DATA,
|
||||||
INITIAL_COUNT_METRIC_VALUE,
|
INITIAL_COLUMN_METRICS_VALUE,
|
||||||
INITIAL_MATH_METRIC_VALUE,
|
|
||||||
INITIAL_PROPORTION_METRIC_VALUE,
|
|
||||||
INITIAL_QUARTILE_METRIC_VALUE,
|
|
||||||
INITIAL_SUM_METRIC_VALUE,
|
|
||||||
} from '../../../constants/profiler.constant';
|
} from '../../../constants/profiler.constant';
|
||||||
import { ColumnProfile } from '../../../generated/entity/data/container';
|
import { ColumnProfile } from '../../../generated/entity/data/container';
|
||||||
import { Table } from '../../../generated/entity/data/table';
|
import { Table } from '../../../generated/entity/data/table';
|
||||||
import { getColumnProfilerList } from '../../../rest/tableAPI';
|
import { getColumnProfilerList } from '../../../rest/tableAPI';
|
||||||
import { getEncodedFqn } from '../../../utils/StringsUtils';
|
import { getEncodedFqn } from '../../../utils/StringsUtils';
|
||||||
import {
|
import {
|
||||||
|
calculateColumnProfilerMetrics,
|
||||||
calculateCustomMetrics,
|
calculateCustomMetrics,
|
||||||
getColumnCustomMetric,
|
getColumnCustomMetric,
|
||||||
} from '../../../utils/TableProfilerUtils';
|
} from '../../../utils/TableProfilerUtils';
|
||||||
|
import { ColumnMetricsInterface } from '../../../utils/TableProfilerUtils.interface';
|
||||||
import { showErrorToast } from '../../../utils/ToastUtils';
|
import { showErrorToast } from '../../../utils/ToastUtils';
|
||||||
import { customFormatDateTime } from '../../../utils/date-time/DateTimeUtils';
|
|
||||||
import CustomMetricGraphs from '../CustomMetricGraphs/CustomMetricGraphs.component';
|
import CustomMetricGraphs from '../CustomMetricGraphs/CustomMetricGraphs.component';
|
||||||
import { useTableProfiler } from '../TableProfilerProvider';
|
import { useTableProfiler } from '../TableProfilerProvider';
|
||||||
|
|
||||||
@ -67,22 +63,10 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
[tableCustomMetric, activeColumnFqn, tableDetails]
|
[tableCustomMetric, activeColumnFqn, tableDetails]
|
||||||
);
|
);
|
||||||
const [countMetrics, setCountMetrics] = useState<MetricChartType>(
|
const [columnMetric, setColumnMetric] = useState<ColumnMetricsInterface>(
|
||||||
INITIAL_COUNT_METRIC_VALUE
|
INITIAL_COLUMN_METRICS_VALUE
|
||||||
);
|
|
||||||
const [proportionMetrics, setProportionMetrics] = useState<MetricChartType>(
|
|
||||||
INITIAL_PROPORTION_METRIC_VALUE
|
|
||||||
);
|
|
||||||
const [mathMetrics, setMathMetrics] = useState<MetricChartType>(
|
|
||||||
INITIAL_MATH_METRIC_VALUE
|
|
||||||
);
|
|
||||||
const [sumMetrics, setSumMetrics] = useState<MetricChartType>(
|
|
||||||
INITIAL_SUM_METRIC_VALUE
|
|
||||||
);
|
);
|
||||||
const [isMinMaxStringData, setIsMinMaxStringData] = useState(false);
|
const [isMinMaxStringData, setIsMinMaxStringData] = useState(false);
|
||||||
const [quartileMetrics, setQuartileMetrics] = useState<MetricChartType>(
|
|
||||||
INITIAL_QUARTILE_METRIC_VALUE
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnCustomMetrics = useMemo(
|
const columnCustomMetrics = useMemo(
|
||||||
() => calculateCustomMetrics(columnProfilerData, customMetrics),
|
() => calculateCustomMetrics(columnProfilerData, customMetrics),
|
||||||
@ -115,116 +99,17 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
}, [columnProfilerData]);
|
}, [columnProfilerData]);
|
||||||
|
|
||||||
const createMetricsChartData = () => {
|
const createMetricsChartData = () => {
|
||||||
const updateProfilerData = sortBy(columnProfilerData, 'timestamp');
|
const profileMetric = calculateColumnProfilerMetrics({
|
||||||
const countMetricData: MetricChartType['data'] = [];
|
columnProfilerData,
|
||||||
const proportionMetricData: MetricChartType['data'] = [];
|
...columnMetric,
|
||||||
const mathMetricData: MetricChartType['data'] = [];
|
|
||||||
const sumMetricData: MetricChartType['data'] = [];
|
|
||||||
const quartileMetricData: MetricChartType['data'] = [];
|
|
||||||
updateProfilerData.forEach((col) => {
|
|
||||||
const timestamp = customFormatDateTime(col.timestamp, 'MMM dd, hh:mm');
|
|
||||||
|
|
||||||
countMetricData.push({
|
|
||||||
name: timestamp,
|
|
||||||
timestamp: col.timestamp,
|
|
||||||
distinctCount: col.distinctCount || 0,
|
|
||||||
nullCount: col.nullCount || 0,
|
|
||||||
uniqueCount: col.uniqueCount || 0,
|
|
||||||
valuesCount: col.valuesCount || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
sumMetricData.push({
|
|
||||||
name: timestamp,
|
|
||||||
timestamp: col.timestamp || 0,
|
|
||||||
sum: col.sum || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
mathMetricData.push({
|
|
||||||
name: timestamp,
|
|
||||||
timestamp: col.timestamp || 0,
|
|
||||||
max: col.max || 0,
|
|
||||||
min: col.min || 0,
|
|
||||||
mean: col.mean || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
proportionMetricData.push({
|
|
||||||
name: timestamp,
|
|
||||||
timestamp: col.timestamp || 0,
|
|
||||||
distinctProportion: Math.round((col.distinctProportion || 0) * 100),
|
|
||||||
nullProportion: Math.round((col.nullProportion || 0) * 100),
|
|
||||||
uniqueProportion: Math.round((col.uniqueProportion || 0) * 100),
|
|
||||||
});
|
|
||||||
|
|
||||||
quartileMetricData.push({
|
|
||||||
name: timestamp,
|
|
||||||
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) => ({
|
setColumnMetric(profileMetric);
|
||||||
...item,
|
|
||||||
latestValue:
|
|
||||||
countMetricData[countMetricData.length - 1]?.[item.dataKey] || 0,
|
|
||||||
}));
|
|
||||||
const proportionMetricInfo = proportionMetrics.information.map((item) => ({
|
|
||||||
...item,
|
|
||||||
latestValue: parseFloat(
|
|
||||||
`${
|
|
||||||
proportionMetricData[proportionMetricData.length - 1]?.[
|
|
||||||
item.dataKey
|
|
||||||
] || 0
|
|
||||||
}`
|
|
||||||
).toFixed(2),
|
|
||||||
}));
|
|
||||||
const mathMetricInfo = mathMetrics.information.map((item) => ({
|
|
||||||
...item,
|
|
||||||
latestValue:
|
|
||||||
mathMetricData[mathMetricData.length - 1]?.[item.dataKey] || 0,
|
|
||||||
}));
|
|
||||||
const sumMetricInfo = sumMetrics.information.map((item) => ({
|
|
||||||
...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,
|
|
||||||
information: countMetricInfo,
|
|
||||||
data: countMetricData,
|
|
||||||
}));
|
|
||||||
setProportionMetrics((pre) => ({
|
|
||||||
...pre,
|
|
||||||
information: proportionMetricInfo,
|
|
||||||
data: proportionMetricData,
|
|
||||||
}));
|
|
||||||
setMathMetrics((pre) => ({
|
|
||||||
...pre,
|
|
||||||
information: mathMetricInfo,
|
|
||||||
data: mathMetricData,
|
|
||||||
}));
|
|
||||||
setSumMetrics((pre) => ({
|
|
||||||
...pre,
|
|
||||||
information: sumMetricInfo,
|
|
||||||
data: sumMetricData,
|
|
||||||
}));
|
|
||||||
setQuartileMetrics((pre) => ({
|
|
||||||
...pre,
|
|
||||||
information: quartileMetricInfo,
|
|
||||||
data: quartileMetricData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// only min/max category can be string
|
// only min/max category can be string
|
||||||
const isMinMaxString =
|
const isMinMaxString =
|
||||||
isString(updateProfilerData[0]?.min) ||
|
isString(columnProfilerData[0]?.min) ||
|
||||||
isString(updateProfilerData[0]?.max);
|
isString(columnProfilerData[0]?.max);
|
||||||
setIsMinMaxStringData(isMinMaxString);
|
setIsMinMaxStringData(isMinMaxString);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -243,7 +128,7 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
gutter={[16, 16]}>
|
gutter={[16, 16]}>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<ProfilerDetailsCard
|
<ProfilerDetailsCard
|
||||||
chartCollection={countMetrics}
|
chartCollection={columnMetric.countMetrics}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
name="count"
|
name="count"
|
||||||
title={t('label.data-count-plural')}
|
title={t('label.data-count-plural')}
|
||||||
@ -251,7 +136,7 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<ProfilerDetailsCard
|
<ProfilerDetailsCard
|
||||||
chartCollection={proportionMetrics}
|
chartCollection={columnMetric.proportionMetrics}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
name="proportion"
|
name="proportion"
|
||||||
tickFormatter="%"
|
tickFormatter="%"
|
||||||
@ -260,7 +145,7 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<ProfilerDetailsCard
|
<ProfilerDetailsCard
|
||||||
chartCollection={mathMetrics}
|
chartCollection={columnMetric.mathMetrics}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
name="math"
|
name="math"
|
||||||
showYAxisCategory={isMinMaxStringData}
|
showYAxisCategory={isMinMaxStringData}
|
||||||
@ -270,7 +155,7 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<ProfilerDetailsCard
|
<ProfilerDetailsCard
|
||||||
chartCollection={sumMetrics}
|
chartCollection={columnMetric.sumMetrics}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
name="sum"
|
name="sum"
|
||||||
title={t('label.data-aggregate')}
|
title={t('label.data-aggregate')}
|
||||||
@ -278,7 +163,7 @@ const SingleColumnProfile: FC<SingleColumnProfileProps> = ({
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<ProfilerDetailsCard
|
<ProfilerDetailsCard
|
||||||
chartCollection={quartileMetrics}
|
chartCollection={columnMetric.quartileMetrics}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
name="quartile"
|
name="quartile"
|
||||||
title={t('label.data-quartile-plural')}
|
title={t('label.data-quartile-plural')}
|
||||||
|
|||||||
@ -417,3 +417,11 @@ export const TEST_CASE_STATUS_OPTION = [
|
|||||||
value: value,
|
value: value,
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const INITIAL_COLUMN_METRICS_VALUE = {
|
||||||
|
countMetrics: INITIAL_COUNT_METRIC_VALUE,
|
||||||
|
proportionMetrics: INITIAL_PROPORTION_METRIC_VALUE,
|
||||||
|
mathMetrics: INITIAL_MATH_METRIC_VALUE,
|
||||||
|
sumMetrics: INITIAL_SUM_METRIC_VALUE,
|
||||||
|
quartileMetrics: INITIAL_QUARTILE_METRIC_VALUE,
|
||||||
|
};
|
||||||
|
|||||||
@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* 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 { isHasKey } from './ObjectUtils';
|
||||||
|
|
||||||
|
const mockIsHasKeyData = {
|
||||||
|
name: 'John',
|
||||||
|
age: 30,
|
||||||
|
address: '123 Main St',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ObjectUtils', () => {
|
||||||
|
it('isHasKey should return true if all keys are present in the object', () => {
|
||||||
|
const keys = ['name', 'age', 'address'];
|
||||||
|
|
||||||
|
const result = isHasKey(mockIsHasKeyData, keys, true);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isHasKey should return false if all keys are not present in the object', () => {
|
||||||
|
const keys = ['name', 'age', 'address', 'gender'];
|
||||||
|
|
||||||
|
const result = isHasKey(mockIsHasKeyData, keys, true);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isHasKey should return true if at least one key is present in the object', () => {
|
||||||
|
const keys = ['name', 'gender'];
|
||||||
|
|
||||||
|
const result = isHasKey(mockIsHasKeyData, keys);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isHasKey should return false if none of the keys are present in the object', () => {
|
||||||
|
const keys = ['gender', 'occupation'];
|
||||||
|
|
||||||
|
const result = isHasKey(mockIsHasKeyData, keys);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* 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 { has } from 'lodash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given object has the specified key(s).
|
||||||
|
*
|
||||||
|
* @template T - The type of the object.
|
||||||
|
* @param {T} data - The object to check.
|
||||||
|
* @param {string[]} keys - The key(s) to check for.
|
||||||
|
* @param {boolean} checkAllKey - If true, checks if all keys are present; if false, checks if any key is present.
|
||||||
|
* @returns {boolean} True if the object has the key(s), false otherwise.
|
||||||
|
*/
|
||||||
|
export const isHasKey = <T>(
|
||||||
|
data: T,
|
||||||
|
keys: string[],
|
||||||
|
checkAllKey = false
|
||||||
|
): boolean => {
|
||||||
|
const func = (item: string) => has(data, item);
|
||||||
|
|
||||||
|
return checkAllKey ? keys.every(func) : keys.some(func);
|
||||||
|
};
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* 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 { MetricChartType } from '../components/ProfilerDashboard/profilerDashboard.interface';
|
||||||
|
import { ColumnProfile } from '../generated/entity/data/table';
|
||||||
|
|
||||||
|
export interface ColumnMetricsInterface {
|
||||||
|
countMetrics: MetricChartType;
|
||||||
|
proportionMetrics: MetricChartType;
|
||||||
|
mathMetrics: MetricChartType;
|
||||||
|
sumMetrics: MetricChartType;
|
||||||
|
quartileMetrics: MetricChartType;
|
||||||
|
}
|
||||||
|
export interface CalculateColumnProfilerMetricsInterface
|
||||||
|
extends ColumnMetricsInterface {
|
||||||
|
columnProfilerData: ColumnProfile[];
|
||||||
|
}
|
||||||
@ -12,9 +12,11 @@
|
|||||||
*/
|
*/
|
||||||
import { Table, TableProfile } from '../generated/entity/data/table';
|
import { Table, TableProfile } from '../generated/entity/data/table';
|
||||||
import {
|
import {
|
||||||
|
calculateColumnProfilerMetrics,
|
||||||
calculateCustomMetrics,
|
calculateCustomMetrics,
|
||||||
getColumnCustomMetric,
|
getColumnCustomMetric,
|
||||||
} from './TableProfilerUtils';
|
} from './TableProfilerUtils';
|
||||||
|
import { CalculateColumnProfilerMetricsInterface } from './TableProfilerUtils.interface';
|
||||||
|
|
||||||
jest.mock('./date-time/DateTimeUtils', () => {
|
jest.mock('./date-time/DateTimeUtils', () => {
|
||||||
return {
|
return {
|
||||||
@ -22,11 +24,71 @@ jest.mock('./date-time/DateTimeUtils', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const columnFqn = 'fqn1';
|
||||||
|
const customMetrics = [
|
||||||
|
{
|
||||||
|
id: 'id1',
|
||||||
|
name: 'name1',
|
||||||
|
expression: 'expression1',
|
||||||
|
updatedAt: 1701757494892,
|
||||||
|
updatedBy: 'admin',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const table = {
|
||||||
|
fullyQualifiedName: 'fqn',
|
||||||
|
name: 'name',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
fullyQualifiedName: 'fqn1',
|
||||||
|
name: 'name1',
|
||||||
|
customMetrics: customMetrics,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as Table;
|
||||||
|
|
||||||
|
const countMetrics = {
|
||||||
|
information: [
|
||||||
|
{ label: 'Distinct Count', dataKey: 'distinctCount' },
|
||||||
|
{ label: 'Null Count', dataKey: 'nullCount' },
|
||||||
|
{ label: 'Unique Count', dataKey: 'uniqueCount' },
|
||||||
|
{ label: 'Values Count', dataKey: 'valuesCount' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const proportionMetrics = {
|
||||||
|
information: [
|
||||||
|
{ label: 'Distinct Proportion', dataKey: 'distinctProportion' },
|
||||||
|
{ label: 'Null Proportion', dataKey: 'nullProportion' },
|
||||||
|
{ label: 'Unique Proportion', dataKey: 'uniqueProportion' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mathMetrics = {
|
||||||
|
information: [
|
||||||
|
{ label: 'Max', dataKey: 'max' },
|
||||||
|
{ label: 'Min', dataKey: 'min' },
|
||||||
|
{ label: 'Mean', dataKey: 'mean' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sumMetrics = {
|
||||||
|
information: [{ label: 'Sum', dataKey: 'sum' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const quartileMetrics = {
|
||||||
|
information: [
|
||||||
|
{ label: 'First Quartile', dataKey: 'firstQuartile' },
|
||||||
|
{ label: 'Third Quartile', dataKey: 'thirdQuartile' },
|
||||||
|
{ label: 'Inter Quartile Range', dataKey: 'interQuartileRange' },
|
||||||
|
{ label: 'Median', dataKey: 'median' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
describe('TableProfilerUtils', () => {
|
describe('TableProfilerUtils', () => {
|
||||||
it('calculateCustomMetrics should return correct data', () => {
|
it('calculateCustomMetrics should return correct data', () => {
|
||||||
const profiler = [
|
const profiler = [
|
||||||
{
|
{
|
||||||
timestamp: 1701757499196,
|
timestamp: 1701757494892,
|
||||||
profileSampleType: 'PERCENTAGE',
|
profileSampleType: 'PERCENTAGE',
|
||||||
columnCount: 12,
|
columnCount: 12,
|
||||||
rowCount: 14567,
|
rowCount: 14567,
|
||||||
@ -69,14 +131,14 @@ describe('TableProfilerUtils', () => {
|
|||||||
{
|
{
|
||||||
CountOfFRAddress: 1467,
|
CountOfFRAddress: 1467,
|
||||||
formattedTimestamp: 'Dec 05, 11:54',
|
formattedTimestamp: 'Dec 05, 11:54',
|
||||||
timestamp: 1701757499196,
|
timestamp: 1701757494892,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
CountOfUSAddress: [
|
CountOfUSAddress: [
|
||||||
{
|
{
|
||||||
CountOfUSAddress: 15467,
|
CountOfUSAddress: 15467,
|
||||||
formattedTimestamp: 'Dec 05, 11:54',
|
formattedTimestamp: 'Dec 05, 11:54',
|
||||||
timestamp: 1701757499196,
|
timestamp: 1701757494892,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -89,54 +151,12 @@ describe('TableProfilerUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('getColumnCustomMetric should return correct data', () => {
|
it('getColumnCustomMetric should return correct data', () => {
|
||||||
const customMetrics = [
|
|
||||||
{
|
|
||||||
id: 'id1',
|
|
||||||
name: 'name1',
|
|
||||||
expression: 'expression1',
|
|
||||||
updatedAt: 1701757494892,
|
|
||||||
updatedBy: 'admin',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const table = {
|
|
||||||
fullyQualifiedName: 'fqn',
|
|
||||||
name: 'name',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
fullyQualifiedName: 'fqn1',
|
|
||||||
name: 'name1',
|
|
||||||
customMetrics: customMetrics,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as Table;
|
|
||||||
const columnFqn = 'fqn1';
|
|
||||||
const data = getColumnCustomMetric(table, columnFqn);
|
const data = getColumnCustomMetric(table, columnFqn);
|
||||||
|
|
||||||
expect(data).toEqual(customMetrics);
|
expect(data).toEqual(customMetrics);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getColumnCustomMetric should return undefined if table, fqn and both is not provided', () => {
|
it('getColumnCustomMetric should return undefined if table, fqn and both is not provided', () => {
|
||||||
const columnFqn = 'fqn1';
|
|
||||||
const customMetrics = [
|
|
||||||
{
|
|
||||||
id: 'id1',
|
|
||||||
name: 'name1',
|
|
||||||
expression: 'expression1',
|
|
||||||
updatedAt: 1701757494892,
|
|
||||||
updatedBy: 'admin',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const table = {
|
|
||||||
fullyQualifiedName: 'fqn',
|
|
||||||
name: 'name',
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
fullyQualifiedName: 'fqn1',
|
|
||||||
name: 'name1',
|
|
||||||
customMetrics: customMetrics,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as Table;
|
|
||||||
const withoutTable = getColumnCustomMetric(undefined, columnFqn);
|
const withoutTable = getColumnCustomMetric(undefined, columnFqn);
|
||||||
const withoutFqn = getColumnCustomMetric(table, undefined);
|
const withoutFqn = getColumnCustomMetric(table, undefined);
|
||||||
const emptyData = getColumnCustomMetric();
|
const emptyData = getColumnCustomMetric();
|
||||||
@ -145,4 +165,142 @@ describe('TableProfilerUtils', () => {
|
|||||||
expect(withoutFqn).toBeUndefined();
|
expect(withoutFqn).toBeUndefined();
|
||||||
expect(emptyData).toBeUndefined();
|
expect(emptyData).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calculateColumnProfilerMetrics should calculate column profiler metrics correctly', () => {
|
||||||
|
const columnProfilerData = [
|
||||||
|
{
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctCount: 100,
|
||||||
|
nullCount: 10,
|
||||||
|
uniqueCount: 90,
|
||||||
|
valuesCount: 200,
|
||||||
|
sum: 500,
|
||||||
|
max: 100,
|
||||||
|
min: 0,
|
||||||
|
mean: 50,
|
||||||
|
distinctProportion: 0.5,
|
||||||
|
nullProportion: 0.05,
|
||||||
|
uniqueProportion: 0.45,
|
||||||
|
firstQuartile: 25,
|
||||||
|
thirdQuartile: 75,
|
||||||
|
interQuartileRange: 50,
|
||||||
|
median: 50,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateColumnProfilerMetrics({
|
||||||
|
columnProfilerData,
|
||||||
|
countMetrics,
|
||||||
|
proportionMetrics,
|
||||||
|
mathMetrics,
|
||||||
|
sumMetrics,
|
||||||
|
quartileMetrics,
|
||||||
|
} as unknown as CalculateColumnProfilerMetricsInterface);
|
||||||
|
|
||||||
|
expect(result.countMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctCount: 100,
|
||||||
|
nullCount: 10,
|
||||||
|
uniqueCount: 90,
|
||||||
|
valuesCount: 200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.proportionMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctProportion: 50,
|
||||||
|
nullProportion: 5,
|
||||||
|
uniqueProportion: 45,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.mathMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
max: 100,
|
||||||
|
min: 0,
|
||||||
|
mean: 50,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.sumMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
sum: 500,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.quartileMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
firstQuartile: 25,
|
||||||
|
thirdQuartile: 75,
|
||||||
|
interQuartileRange: 50,
|
||||||
|
median: 50,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculateColumnProfilerMetrics should only calculate metric based on available data', () => {
|
||||||
|
const columnProfilerData = [
|
||||||
|
{
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctCount: 100,
|
||||||
|
nullCount: 10,
|
||||||
|
uniqueCount: 90,
|
||||||
|
valuesCount: 200,
|
||||||
|
max: 100,
|
||||||
|
min: 0,
|
||||||
|
distinctProportion: 0.5,
|
||||||
|
nullProportion: 0.05,
|
||||||
|
uniqueProportion: 0.45,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = calculateColumnProfilerMetrics({
|
||||||
|
columnProfilerData,
|
||||||
|
countMetrics,
|
||||||
|
proportionMetrics,
|
||||||
|
mathMetrics,
|
||||||
|
sumMetrics,
|
||||||
|
quartileMetrics,
|
||||||
|
} as unknown as CalculateColumnProfilerMetricsInterface);
|
||||||
|
|
||||||
|
expect(result.countMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctCount: 100,
|
||||||
|
nullCount: 10,
|
||||||
|
uniqueCount: 90,
|
||||||
|
valuesCount: 200,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.proportionMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
distinctProportion: 50,
|
||||||
|
nullProportion: 5,
|
||||||
|
uniqueProportion: 45,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.mathMetrics.data).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Dec 05, 11:54',
|
||||||
|
timestamp: 1701757494892,
|
||||||
|
max: 100,
|
||||||
|
min: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.sumMetrics.data).toEqual([]);
|
||||||
|
expect(result.quartileMetrics.data).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,12 +11,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { findLast, isUndefined, sortBy } from 'lodash';
|
import { findLast, isUndefined, last, sortBy } from 'lodash';
|
||||||
import { MetricChartType } from '../components/ProfilerDashboard/profilerDashboard.interface';
|
import { MetricChartType } from '../components/ProfilerDashboard/profilerDashboard.interface';
|
||||||
import { SystemProfile } from '../generated/api/data/createTableProfile';
|
import { SystemProfile } from '../generated/api/data/createTableProfile';
|
||||||
import { Table, TableProfile } from '../generated/entity/data/table';
|
import { Table, TableProfile } from '../generated/entity/data/table';
|
||||||
import { CustomMetric } from '../generated/tests/customMetric';
|
import { CustomMetric } from '../generated/tests/customMetric';
|
||||||
import { customFormatDateTime } from './date-time/DateTimeUtils';
|
import { customFormatDateTime } from './date-time/DateTimeUtils';
|
||||||
|
import { isHasKey } from './ObjectUtils';
|
||||||
|
import {
|
||||||
|
CalculateColumnProfilerMetricsInterface,
|
||||||
|
ColumnMetricsInterface,
|
||||||
|
} from './TableProfilerUtils.interface';
|
||||||
|
|
||||||
export const calculateRowCountMetrics = (
|
export const calculateRowCountMetrics = (
|
||||||
profiler: TableProfile[],
|
profiler: TableProfile[],
|
||||||
@ -31,13 +36,13 @@ export const calculateRowCountMetrics = (
|
|||||||
rowCountMetricData.push({
|
rowCountMetricData.push({
|
||||||
name: timestamp,
|
name: timestamp,
|
||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
rowCount: Number(data.rowCount),
|
rowCount: data.rowCount,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const countMetricInfo = currentMetrics.information.map((item) => ({
|
const countMetricInfo = currentMetrics.information.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
latestValue:
|
latestValue:
|
||||||
rowCountMetricData[rowCountMetricData.length - 1]?.[item.dataKey] || 0,
|
rowCountMetricData[rowCountMetricData.length - 1]?.[item.dataKey],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { data: rowCountMetricData, information: countMetricInfo };
|
return { data: rowCountMetricData, information: countMetricInfo };
|
||||||
@ -58,13 +63,13 @@ export const calculateSystemMetrics = (
|
|||||||
operationMetrics.push({
|
operationMetrics.push({
|
||||||
name: timestamp,
|
name: timestamp,
|
||||||
timestamp: Number(data.timestamp),
|
timestamp: Number(data.timestamp),
|
||||||
[data.operation || 'value']: Number(data.rowsAffected),
|
[data.operation ?? 'value']: data.rowsAffected,
|
||||||
});
|
});
|
||||||
operationDateMetrics.push({
|
operationDateMetrics.push({
|
||||||
name: timestamp,
|
name: timestamp,
|
||||||
timestamp: Number(data.timestamp),
|
timestamp: Number(data.timestamp),
|
||||||
data: data.rowsAffected || 0,
|
data: data.rowsAffected,
|
||||||
[data.operation || 'value']: 5,
|
[data.operation ?? 'value']: 5,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const operationMetricsInfo = currentMetrics.information.map((item) => {
|
const operationMetricsInfo = currentMetrics.information.map((item) => {
|
||||||
@ -76,7 +81,7 @@ export const calculateSystemMetrics = (
|
|||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
stackId: stackId,
|
stackId: stackId,
|
||||||
latestValue: operation?.rowsAffected ?? 0,
|
latestValue: operation?.rowsAffected,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const operationDateMetricsInfo = currentMetrics.information.map((item) => {
|
const operationDateMetricsInfo = currentMetrics.information.map((item) => {
|
||||||
@ -145,3 +150,145 @@ export const getColumnCustomMetric = (table?: Table, columnFqn?: string) => {
|
|||||||
(column) => column.fullyQualifiedName === columnFqn
|
(column) => column.fullyQualifiedName === columnFqn
|
||||||
)?.customMetrics;
|
)?.customMetrics;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const calculateColumnProfilerMetrics = ({
|
||||||
|
columnProfilerData,
|
||||||
|
countMetrics,
|
||||||
|
proportionMetrics,
|
||||||
|
mathMetrics,
|
||||||
|
sumMetrics,
|
||||||
|
quartileMetrics,
|
||||||
|
}: CalculateColumnProfilerMetricsInterface): ColumnMetricsInterface => {
|
||||||
|
const updateProfilerData = sortBy(columnProfilerData, '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 { timestamp, sum } = col;
|
||||||
|
const name = customFormatDateTime(timestamp, 'MMM dd, hh:mm');
|
||||||
|
const defaultData = { name, timestamp };
|
||||||
|
|
||||||
|
if (
|
||||||
|
isHasKey(col, [
|
||||||
|
'distinctCount',
|
||||||
|
'nullCount',
|
||||||
|
'uniqueCount',
|
||||||
|
'valuesCount',
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
const { distinctCount, nullCount, uniqueCount, valuesCount } = col;
|
||||||
|
countMetricData.push({
|
||||||
|
...defaultData,
|
||||||
|
distinctCount,
|
||||||
|
nullCount,
|
||||||
|
uniqueCount,
|
||||||
|
valuesCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHasKey(col, ['sum'])) {
|
||||||
|
sumMetricData.push({
|
||||||
|
...defaultData,
|
||||||
|
sum,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHasKey(col, ['max', 'min', 'mean'])) {
|
||||||
|
const { max, min, mean } = col;
|
||||||
|
mathMetricData.push({
|
||||||
|
...defaultData,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
mean,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isHasKey(col, [
|
||||||
|
'distinctProportion',
|
||||||
|
'nullProportion',
|
||||||
|
'uniqueProportion',
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
const { distinctProportion, nullProportion, uniqueProportion } = col;
|
||||||
|
proportionMetricData.push({
|
||||||
|
...defaultData,
|
||||||
|
distinctProportion: isUndefined(distinctProportion)
|
||||||
|
? undefined
|
||||||
|
: Math.round(distinctProportion * 100),
|
||||||
|
nullProportion: isUndefined(nullProportion)
|
||||||
|
? undefined
|
||||||
|
: Math.round(nullProportion * 100),
|
||||||
|
uniqueProportion: isUndefined(uniqueProportion)
|
||||||
|
? undefined
|
||||||
|
: Math.round(uniqueProportion * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isHasKey(col, [
|
||||||
|
'firstQuartile',
|
||||||
|
'thirdQuartile',
|
||||||
|
'interQuartileRange',
|
||||||
|
'median',
|
||||||
|
])
|
||||||
|
) {
|
||||||
|
const { firstQuartile, thirdQuartile, interQuartileRange, median } = col;
|
||||||
|
quartileMetricData.push({
|
||||||
|
...defaultData,
|
||||||
|
firstQuartile,
|
||||||
|
thirdQuartile,
|
||||||
|
interQuartileRange,
|
||||||
|
median,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const countMetricInfo = countMetrics.information.map((item) => ({
|
||||||
|
...item,
|
||||||
|
latestValue: last(countMetricData)?.[item.dataKey],
|
||||||
|
}));
|
||||||
|
const proportionMetricInfo = proportionMetrics.information.map((item) => ({
|
||||||
|
...item,
|
||||||
|
latestValue: isUndefined(last(proportionMetricData)?.[item.dataKey])
|
||||||
|
? undefined
|
||||||
|
: parseFloat(`${last(proportionMetricData)?.[item.dataKey]}`).toFixed(2),
|
||||||
|
}));
|
||||||
|
const mathMetricInfo = mathMetrics.information.map((item) => ({
|
||||||
|
...item,
|
||||||
|
latestValue: last(mathMetricData)?.[item.dataKey],
|
||||||
|
}));
|
||||||
|
const sumMetricInfo = sumMetrics.information.map((item) => ({
|
||||||
|
...item,
|
||||||
|
latestValue: last(sumMetricData)?.[item.dataKey],
|
||||||
|
}));
|
||||||
|
const quartileMetricInfo = quartileMetrics.information.map((item) => ({
|
||||||
|
...item,
|
||||||
|
latestValue: last(quartileMetricData)?.[item.dataKey],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
countMetrics: {
|
||||||
|
information: countMetricInfo,
|
||||||
|
data: countMetricData,
|
||||||
|
},
|
||||||
|
proportionMetrics: {
|
||||||
|
information: proportionMetricInfo,
|
||||||
|
data: proportionMetricData,
|
||||||
|
},
|
||||||
|
mathMetrics: {
|
||||||
|
information: mathMetricInfo,
|
||||||
|
data: mathMetricData,
|
||||||
|
},
|
||||||
|
sumMetrics: {
|
||||||
|
information: sumMetricInfo,
|
||||||
|
data: sumMetricData,
|
||||||
|
},
|
||||||
|
quartileMetrics: {
|
||||||
|
information: quartileMetricInfo,
|
||||||
|
data: quartileMetricData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user