Feat: Column level profiler dashboard (#6683)

* added column dashboard page and route

* added redirection from table to profiler dashboard

* fetch the requierd information from api

* added loader and error placeholder

* added profiler dashboard component

* added table summery in column profiler page

* added tab section

* added range dropdown and add test button

* added chart for column level dashboard

* added formatting for chart and data

* extracted profiler tab as componene

* added table level profiler card

* added indicator for test summery

* added description summary card

* added latest profiler filter card

* removed bottom border and added padding-x for table cell

* show laast 7 days opption

* added content on tab switch for profiler and data quality

* remove loading state and updated summary spelling

* removed latest option from profiler dropdown and relavent code

* added more metrics in the graph and made default dropdown option as last 3 days

* change style of Legend component

* fixed failing test

* restrict float number to show only 2 decimal

* addressing comments

* fixed failing unit test
This commit is contained in:
Shailesh Parmar 2022-08-18 18:49:49 +05:30 committed by GitHub
parent 36338eb727
commit bbe424dbed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1238 additions and 89 deletions

View File

@ -18,11 +18,13 @@ import { CreateTableTest } from '../generated/api/tests/createTableTest';
import {
ColumnTestType,
Table,
TableProfile,
TableProfilerConfig,
} from '../generated/entity/data/table';
import { TableTestType } from '../generated/tests/tableTest';
import { EntityHistory } from '../generated/type/entityHistory';
import { EntityReference } from '../generated/type/entityReference';
import { Paging } from '../generated/type/paging';
import { getURLWithQueryFields } from '../utils/APIUtils';
import APIClient from './index';
@ -212,3 +214,23 @@ export const putTableProfileConfig = async (
return response.data;
};
export const getTableProfilesList = async (
tableId: string,
params?: {
startTs?: number;
endTs?: number;
limit?: number;
before?: string;
after?: string;
}
) => {
const url = `/tables/${tableId}/tableProfile`;
const response = await APIClient.get<{
data: TableProfile[];
paging: Paging;
}>(url, { params });
return response.data;
};

View File

@ -0,0 +1,353 @@
/*
* Copyright 2022 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 { Button, Col, Radio, Row, Select, Space } from 'antd';
import { RadioChangeEvent } from 'antd/lib/radio';
import { AxiosError } from 'axios';
import { EntityTags, ExtraInfo } from 'Models';
import React, { useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { addFollower, removeFollower } from '../../axiosAPIs/tableAPI';
import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants';
import {
getDatabaseDetailsPath,
getDatabaseSchemaDetailsPath,
getServiceDetailsPath,
getTableTabPath,
getTeamAndUserDetailsPath,
} from '../../constants/constants';
import { PROFILER_FILTER_RANGE } from '../../constants/profiler.constant';
import { EntityType, FqnPart } from '../../enums/entity.enum';
import { ServiceCategory } from '../../enums/service.enum';
import { OwnerType } from '../../enums/user.enum';
import { Column, Table } from '../../generated/entity/data/table';
import { EntityReference } from '../../generated/type/entityReference';
import { LabelType, State } from '../../generated/type/tagLabel';
import jsonData from '../../jsons/en';
import {
getCurrentUserId,
getEntityName,
getEntityPlaceHolder,
getNameFromFQN,
getPartialNameFromTableFQN,
hasEditAccess,
} from '../../utils/CommonUtils';
import { serviceTypeLogo } from '../../utils/ServiceUtils';
import {
getTagsWithoutTier,
getTierTags,
getUsagePercentile,
} from '../../utils/TableUtils';
import { showErrorToast } from '../../utils/ToastUtils';
import EntityPageInfo from '../common/entityPageInfo/EntityPageInfo';
import PageLayout from '../containers/PageLayout';
import ProfilerTab from './component/ProfilerTab';
import {
ProfilerDashboardProps,
ProfilerDashboardTab,
} from './profilerDashboard.interface';
import './profilerDashboard.less';
const ProfilerDashboard: React.FC<ProfilerDashboardProps> = ({
table,
fetchProfilerData,
profilerData,
onTableChange,
}) => {
const history = useHistory();
const { entityTypeFQN } = useParams<Record<string, string>>();
const [follower, setFollower] = useState<EntityReference[]>([]);
const [isFollowing, setIsFollowing] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<ProfilerDashboardTab>(
ProfilerDashboardTab.PROFILER
);
const [selectedTimeRange, setSelectedTimeRange] =
useState<keyof typeof PROFILER_FILTER_RANGE>('last3days');
const [activeColumnDetails, setActiveColumnDetails] = useState<Column>(
{} as Column
);
const timeRangeOption = useMemo(() => {
return Object.entries(PROFILER_FILTER_RANGE).map(([key, value]) => ({
label: value.title,
value: key,
}));
}, []);
const tier = useMemo(() => getTierTags(table.tags ?? []), [table]);
const breadcrumb = useMemo(() => {
const serviceName = getEntityName(table.service);
const fqn = table.fullyQualifiedName || '';
const columnName = getNameFromFQN(entityTypeFQN);
return [
{
name: getEntityName(table.service),
url: serviceName
? getServiceDetailsPath(
serviceName,
ServiceCategory.DATABASE_SERVICES
)
: '',
imgSrc: table.serviceType
? serviceTypeLogo(table.serviceType)
: undefined,
},
{
name: getPartialNameFromTableFQN(fqn, [FqnPart.Database]),
url: getDatabaseDetailsPath(fqn),
},
{
name: getPartialNameFromTableFQN(fqn, [FqnPart.Schema]),
url: getDatabaseSchemaDetailsPath(fqn),
},
{
name: getEntityName(table),
url: getTableTabPath(table.fullyQualifiedName || ''),
},
{
name: columnName,
url: '',
activeTitle: true,
},
];
}, [table]);
const extraInfo: Array<ExtraInfo> = useMemo(() => {
return [
{
key: 'Owner',
value:
table.owner?.type === OwnerType.TEAM
? getTeamAndUserDetailsPath(table.owner?.name || '')
: getEntityName(table.owner),
placeholderText: getEntityPlaceHolder(
getEntityName(table.owner),
table.owner?.deleted
),
isLink: table.owner?.type === OwnerType.TEAM,
openInNewTab: false,
profileName:
table.owner?.type === OwnerType.USER ? table.owner?.name : undefined,
},
{
key: 'Tier',
value: tier?.tagFQN ? tier.tagFQN.split(FQN_SEPARATOR_CHAR)[1] : '',
},
{ key: 'Type', value: `${table.tableType}`, showLabel: true },
{
value:
getUsagePercentile(
table.usageSummary?.weeklyStats?.percentileRank || 0,
true
) || '--',
},
{
value: `${
table.usageSummary?.weeklyStats?.count.toLocaleString() || '--'
} queries`,
},
];
}, [table]);
const handleOwnerUpdate = (newOwner?: Table['owner']) => {
if (newOwner) {
const updatedTableDetails = {
...table,
owner: {
...table.owner,
...newOwner,
},
};
onTableChange(updatedTableDetails);
}
};
const handleTierUpdate = (newTier?: string) => {
if (newTier) {
const tierTag: Table['tags'] = newTier
? [
...getTagsWithoutTier(table.tags as Array<EntityTags>),
{
tagFQN: newTier,
labelType: LabelType.Manual,
state: State.Confirmed,
},
]
: table.tags;
const updatedTableDetails = {
...table,
tags: tierTag,
};
return onTableChange(updatedTableDetails);
} else {
return Promise.reject();
}
};
/**
* Formulates updated tags and updates table entity data for API call
* @param selectedTags
*/
const handleTagUpdate = (selectedTags?: Array<EntityTags>) => {
if (selectedTags) {
const updatedTags = [...(tier ? [tier] : []), ...selectedTags];
const updatedTable = { ...table, tags: updatedTags };
onTableChange(updatedTable);
}
};
const unfollowTable = async () => {
try {
const data = await removeFollower(table.id, getCurrentUserId());
const { oldValue } = data.changeDescription.fieldsDeleted[0];
setFollower(
follower.filter((follower) => follower.id !== oldValue[0].id)
);
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['update-entity-unfollow-error']
);
}
};
const followTable = async () => {
try {
const data = await addFollower(table.id, getCurrentUserId());
const { newValue } = data.changeDescription.fieldsAdded[0];
setFollower([...follower, ...newValue]);
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['update-entity-follow-error']
);
}
};
const handleFollowClick = () => {
if (isFollowing) {
setIsFollowing(false);
unfollowTable();
} else {
setIsFollowing(true);
followTable();
}
};
const handleTabChange = (e: RadioChangeEvent) => {
const value = e.target.value as ProfilerDashboardTab;
if (ProfilerDashboardTab.SUMMARY === value) {
history.push(getTableTabPath(table.fullyQualifiedName || '', 'profiler'));
}
setActiveTab(value);
};
const handleAddTestClick = () => {
history.push(
getTableTabPath(table.fullyQualifiedName || '', 'data-quality')
);
};
const handleTimeRangeChange = (value: keyof typeof PROFILER_FILTER_RANGE) => {
if (value !== selectedTimeRange) {
setSelectedTimeRange(value);
fetchProfilerData(table.id, PROFILER_FILTER_RANGE[value].days);
}
};
useEffect(() => {
if (table) {
const columnName = getNameFromFQN(entityTypeFQN);
const selectedColumn = table.columns.find(
(col) => col.name === columnName
);
setActiveColumnDetails(selectedColumn || ({} as Column));
setFollower(table?.followers || []);
setIsFollowing(
follower.some(({ id }: { id: string }) => id === getCurrentUserId())
);
}
}, [table]);
return (
<PageLayout>
<Row gutter={[16, 16]}>
<Col span={24}>
<EntityPageInfo
isTagEditable
deleted={table.deleted}
entityFqn={table.fullyQualifiedName}
entityId={table.id}
entityName={table.name}
entityType={EntityType.TABLE}
extraInfo={extraInfo}
followHandler={handleFollowClick}
followers={follower.length}
followersList={follower}
hasEditAccess={hasEditAccess(
table.owner?.type || '',
table.owner?.id || ''
)}
isFollowing={isFollowing}
tags={getTagsWithoutTier(table.tags || [])}
tagsHandler={handleTagUpdate}
tier={tier}
titleLinks={breadcrumb}
updateOwner={handleOwnerUpdate}
updateTier={handleTierUpdate}
/>
</Col>
<Col span={24}>
<Row justify="space-between">
<Radio.Group
buttonStyle="solid"
optionType="button"
options={Object.values(ProfilerDashboardTab)}
value={activeTab}
onChange={handleTabChange}
/>
<Space size={16}>
<Select
className="tw-w-32"
options={timeRangeOption}
value={selectedTimeRange}
onChange={handleTimeRangeChange}
/>
<Button type="primary" onClick={handleAddTestClick}>
Add Test
</Button>
</Space>
</Row>
</Col>
{activeTab === ProfilerDashboardTab.PROFILER && (
<Col span={24}>
<ProfilerTab
activeColumnDetails={activeColumnDetails}
profilerData={profilerData}
/>
</Col>
)}
{activeTab === ProfilerDashboardTab.DATA_QUALITY && (
<Col span={24}>Data Quality</Col>
)}
</Row>
</PageLayout>
);
};
export default ProfilerDashboard;

View File

@ -0,0 +1,92 @@
/*
* Copyright 2022 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 { Card, Col, Empty, Row, Space, Statistic } from 'antd';
import React from 'react';
import {
Legend,
LegendValueFormatter,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { ProfilerDetailsCardProps } from '../profilerDashboard.interface';
const ProfilerDetailsCard: React.FC<ProfilerDetailsCardProps> = ({
chartCollection,
tickFormatter,
}) => {
const { data, information } = chartCollection;
const renderColorfulLegendText: LegendValueFormatter = (value, entry) => {
return <span style={{ color: entry?.color }}>{value}</span>;
};
return (
<Card className="tw-rounded-md tw-border">
<Row gutter={[16, 16]}>
<Col span={4}>
<Space direction="vertical" size={16}>
{information.map((info) => (
<Statistic
key={info.title}
title={<span className="tw-text-grey-body">{info.title}</span>}
value={
tickFormatter
? `${info.latestValue}${tickFormatter}`
: info.latestValue
}
valueStyle={{ color: info.color }}
/>
))}
</Space>
</Col>
<Col span={20}>
{data.length > 0 ? (
<ResponsiveContainer minHeight={300}>
<LineChart className="tw-w-full" data={data}>
<XAxis dataKey="name" padding={{ left: 16, right: 16 }} />
<YAxis
allowDataOverflow
padding={{ top: 16, bottom: 16 }}
tickFormatter={(props) =>
tickFormatter ? `${props}${tickFormatter}` : props
}
/>
<Tooltip />
{information.map((info) => (
<Line
dataKey={info.dataKey}
key={info.dataKey}
name={info.title}
stroke={info.color}
type="monotone"
/>
))}
<Legend formatter={renderColorfulLegendText} />
</LineChart>
</ResponsiveContainer>
) : (
<Empty description="No Data Available" />
)}
</Col>
</Row>
</Card>
);
};
export default ProfilerDetailsCard;

View File

@ -0,0 +1,53 @@
/*
* Copyright 2022 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 { Card, Col, Row, Statistic } from 'antd';
import React from 'react';
import { TestCaseStatus } from '../../../generated/tests/tableTest';
import { formTwoDigitNmber } from '../../../utils/CommonUtils';
import TestIndicator from '../../common/TestIndicator/TestIndicator';
import { ProfilerSummaryCardProps } from '../profilerDashboard.interface';
const ProfilerSummaryCard: React.FC<ProfilerSummaryCardProps> = ({
data,
title,
showIndicator = false,
}) => {
return (
<Card className="tw-rounded-md tw-border">
<p className="tw-text-base tw-font-medium tw-mb-7">{title}</p>
<Row className="table-profiler-summary">
{data.map((item) => (
<Col className="overall-summary-card" key={item.title} span={8}>
<Statistic
title={item.title}
value={item.value}
valueRender={(prop) =>
showIndicator ? (
<TestIndicator
type={item.title as TestCaseStatus}
value={formTwoDigitNmber(item.value as number)}
/>
) : (
prop
)
}
/>
</Col>
))}
</Row>
</Card>
);
};
export default ProfilerSummaryCard;

View File

@ -0,0 +1,228 @@
/*
* Copyright 2022 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 { Card, Col, Row, Statistic } from 'antd';
import moment from 'moment';
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
INITIAL_COUNT_METRIC_VALUE,
INITIAL_MATH_METRIC_VALUE,
INITIAL_PROPORTION_METRIC_VALUE,
INITIAL_SUM_METRIC_VALUE,
} from '../../../constants/profiler.constant';
import { getNameFromFQN } from '../../../utils/CommonUtils';
import Ellipses from '../../common/Ellipses/Ellipses';
import {
MetricChartType,
ProfilerTabProps,
} from '../profilerDashboard.interface';
import ProfilerDetailsCard from './ProfilerDetailsCard';
import ProfilerSummaryCard from './ProfilerSummaryCard';
const ProfilerTab: React.FC<ProfilerTabProps> = ({
activeColumnDetails,
profilerData,
}) => {
const { entityTypeFQN } = useParams<Record<string, string>>();
const [countMetrics, setCountMetrics] = useState<MetricChartType>(
INITIAL_COUNT_METRIC_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 tableState = useMemo(
() => [
{
title: 'Row Count',
value: profilerData[0]?.rowCount || 0,
},
{
title: 'Column Count',
value: profilerData[0]?.columnCount || 0,
},
{
title: 'Table Sample %',
value: `${profilerData[0]?.profileSample || 100}%`,
},
],
[profilerData]
);
const testSummary = useMemo(
() => [
{
title: 'Success',
value: 0,
},
{
title: 'Aborted',
value: 0,
},
{
title: 'Failed',
value: 0,
},
],
[]
);
const createMetricsChartData = () => {
const columnName = getNameFromFQN(entityTypeFQN);
const countMetricData: MetricChartType['data'] = [];
const proportionMetricData: MetricChartType['data'] = [];
const mathMetricData: MetricChartType['data'] = [];
const sumMetricData: MetricChartType['data'] = [];
profilerData.forEach((item) => {
const x = moment.unix(item.timestamp || 0).format('DD/MMM HH:mm');
const col = item.columnProfile?.find((col) => col.name === columnName);
countMetricData.push({
name: x,
timestamp: item.timestamp || 0,
distinctCount: col?.distinctCount || 0,
nullCount: col?.nullCount || 0,
uniqueCount: col?.uniqueCount || 0,
valuesCount: col?.valuesCount || 0,
});
sumMetricData.push({
name: x,
timestamp: item.timestamp || 0,
sum: col?.sum || 0,
});
mathMetricData.push({
name: x,
timestamp: item.timestamp || 0,
max: (col?.max as number) || 0,
min: (col?.min as number) || 0,
mean: col?.mean || 0,
median: col?.median || 0,
});
proportionMetricData.push({
name: x,
timestamp: item.timestamp || 0,
distinctProportion: col?.distinctProportion || 0,
nullProportion: col?.nullProportion || 0,
uniqueProportion: col?.uniqueProportion || 0,
});
});
const countMetricInfo = countMetrics.information.map((item) => ({
...item,
latestValue: countMetricData[0][item.dataKey],
}));
const proportionMetricInfo = proportionMetrics.information.map((item) => ({
...item,
latestValue: parseFloat(
`${proportionMetricData[0][item.dataKey]}`
).toFixed(2),
}));
const mathMetricInfo = mathMetrics.information.map((item) => ({
...item,
latestValue: mathMetricData[0][item.dataKey],
}));
const sumMetricInfo = sumMetrics.information.map((item) => ({
...item,
latestValue: sumMetricData[0][item.dataKey],
}));
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,
}));
};
useEffect(() => {
createMetricsChartData();
}, [profilerData]);
return (
<Row gutter={[16, 16]}>
<Col span={8}>
<Card className="tw-rounded-md tw-border tw-h-full">
<Row gutter={16}>
<Col span={18}>
<p className="tw-font-medium tw-text-base">Column summary</p>
<Ellipses className="tw-text-grey-muted" rows={4}>
{activeColumnDetails.description}
</Ellipses>
</Col>
<Col span={6}>
<Statistic
title="Data type"
value={activeColumnDetails.dataTypeDisplay || ''}
valueStyle={{
color: '#1890FF',
fontSize: '24px',
fontWeight: 600,
}}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={8}>
<ProfilerSummaryCard data={tableState} title="Table Metrics Summary" />
</Col>
<Col span={8}>
<ProfilerSummaryCard
showIndicator
data={testSummary}
title="Quality Tests Summary"
/>
</Col>
<Col span={24}>
<ProfilerDetailsCard chartCollection={countMetrics} />
</Col>
<Col span={24}>
<ProfilerDetailsCard
chartCollection={proportionMetrics}
tickFormatter="%"
/>
</Col>
<Col span={24}>
<ProfilerDetailsCard chartCollection={mathMetrics} />
</Col>
<Col span={24}>
<ProfilerDetailsCard chartCollection={sumMetrics} />
</Col>
</Row>
);
};
export default ProfilerTab;

View File

@ -0,0 +1,70 @@
/*
* Copyright 2022 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 { Column, Table, TableProfile } from '../../generated/entity/data/table';
export interface ProfilerDashboardProps {
onTableChange: (table: Table) => void;
table: Table;
profilerData: TableProfile[];
fetchProfilerData: (tableId: string, days?: number) => void;
}
export type MetricChartType = {
information: {
title: string;
dataKey: string;
color: string;
latestValue?: string | number;
}[];
data: Record<string, string | number>[];
};
export interface ProfilerDetailsCardProps {
chartCollection: MetricChartType;
tickFormatter?: string;
}
export enum ProfilerDashboardTab {
SUMMARY = 'Summary',
PROFILER = 'Profiler',
DATA_QUALITY = 'Data Quality',
}
export type ChartData = {
name: string;
proportion?: number;
value: number;
timestamp: number;
};
export type ChartCollection = {
data: ChartData[];
color: string;
};
export type ChartDataCollection = Record<string, ChartCollection>;
export interface ProfilerTabProps {
profilerData: TableProfile[];
activeColumnDetails: Column;
}
export interface ProfilerSummaryCardProps {
title: string;
data: {
title: string;
value: number | string;
}[];
showIndicator?: boolean;
}

View File

@ -0,0 +1,36 @@
/*
* Copyright 2022 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.
*/
.table-profiler-summary {
.overall-summary-card:not(:first-child) {
border-left: 1px solid rgb(229, 231, 235);
padding-left: 16px;
}
}
.latest-profiler-data-container {
table {
border: none;
box-shadow: none;
}
.ant-table.ant-table-small .ant-table-thead > tr > th,
.ant-table.ant-table-small .ant-table-tbody > tr > td {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
}

View File

@ -21,6 +21,7 @@ import {
} from '@testing-library/react';
import { ColumnsType } from 'antd/lib/table';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Column, ColumnProfile } from '../../../generated/entity/data/table';
import { MOCK_TABLE } from '../../../mocks/TableData.mock';
import { ColumnProfileTableProps } from '../TableProfiler.interface';
@ -88,7 +89,7 @@ jest.mock('./ProfilerProgressWidget', () => {
</span>
));
});
jest.mock('./TestIndicator', () => {
jest.mock('../../common/TestIndicator/TestIndicator', () => {
return jest.fn().mockImplementation(({ value, type }) => (
<span data-testid="test-indicator">
{value} <span>{type}</span>
@ -109,7 +110,9 @@ describe('Test ColumnProfileTable component', () => {
});
it('should render without crashing', async () => {
render(<ColumnProfileTable {...mockProps} />);
render(<ColumnProfileTable {...mockProps} />, {
wrapper: MemoryRouter,
});
const container = await screen.findByTestId(
'column-profile-table-container'
@ -125,7 +128,10 @@ describe('Test ColumnProfileTable component', () => {
<ColumnProfileTable
{...mockProps}
columns={undefined as unknown as Column[]}
/>
/>,
{
wrapper: MemoryRouter,
}
);
const container = await screen.findByTestId(
@ -138,7 +144,9 @@ describe('Test ColumnProfileTable component', () => {
});
it('search box should work as expected', async () => {
render(<ColumnProfileTable {...mockProps} />);
render(<ColumnProfileTable {...mockProps} />, {
wrapper: MemoryRouter,
});
const searchbox = await screen.findByTestId('searchbar');

View File

@ -14,6 +14,7 @@
import { Button, Space, Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import React, { FC, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
PRIMERY_COLOR,
SECONDARY_COLOR,
@ -23,11 +24,12 @@ import { ColumnProfile } from '../../../generated/entity/data/table';
import { TestCaseStatus } from '../../../generated/tests/tableTest';
import { formatNumberWithComma } from '../../../utils/CommonUtils';
import { getCurrentDatasetTab } from '../../../utils/DatasetDetailsUtils';
import { getProfilerDashboardWithFqnPath } from '../../../utils/RouterUtils';
import Ellipses from '../../common/Ellipses/Ellipses';
import Searchbar from '../../common/searchbar/Searchbar';
import TestIndicator from '../../common/TestIndicator/TestIndicator';
import { ColumnProfileTableProps } from '../TableProfiler.interface';
import ProfilerProgressWidget from './ProfilerProgressWidget';
import TestIndicator from './TestIndicator';
const ColumnProfileTable: FC<ColumnProfileTableProps> = ({
columnProfile,
@ -57,6 +59,18 @@ const ColumnProfileTable: FC<ColumnProfileTableProps> = ({
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string) => {
const data = columns.find((col) => col.name === name);
return (
<Link
to={getProfilerDashboardWithFqnPath(
data?.fullyQualifiedName || ''
)}>
{name}
</Link>
);
},
},
{
title: 'Data Type',

View File

@ -30,7 +30,7 @@ import {
codeMirrorOption,
DEFAULT_INCLUDE_PROFILE,
PROFILER_METRIC,
} from '../../../constants/entity.constants';
} from '../../../constants/profiler.constant';
import {
ColumnProfilerConfig,
TableProfilerConfig,
@ -49,7 +49,7 @@ const ProfilerSettingsModal: React.FC<ProfilerSettingsModalProps> = ({
}) => {
const [data, setData] = useState<TableProfilerConfig>();
const [sqlQuery, setSqlQuery] = useState<string>('');
const [profileSample, setProfileSample] = useState<number>(0);
const [profileSample, setProfileSample] = useState<number>(100);
const [excludeCol, setExcludeCol] = useState<string[]>([]);
const [includeCol, setIncludeCol] = useState<ColumnProfilerConfig[]>(
DEFAULT_INCLUDE_PROFILE
@ -81,7 +81,7 @@ const ProfilerSettingsModal: React.FC<ProfilerSettingsModalProps> = ({
const updateInitialConfig = (tableProfilerConfig: TableProfilerConfig) => {
const { includeColumns } = tableProfilerConfig;
setSqlQuery(tableProfilerConfig.profileQuery || '');
setProfileSample(tableProfilerConfig.profileSample || 0);
setProfileSample(tableProfilerConfig.profileSample || 100);
setExcludeCol(tableProfilerConfig.excludeColumns || []);
if (includeColumns && includeColumns?.length > 0) {
const includeColValue = includeColumns.map((col) => {

View File

@ -51,7 +51,7 @@ export interface ProfilerSettingsModalProps {
}
export interface TestIndicatorProps {
value: number;
value: number | string;
type: TestCaseStatus;
}

View File

@ -50,23 +50,6 @@
}
}
.test-indicator {
display: inline-block;
height: 8px;
width: 8px;
border-radius: 50%;
&.success {
background: @succesColor;
}
&.failed {
background: @failedColor;
}
&.aborted {
background: @abortedColor;
}
}
.ant-table-row .ant-table-cell:first-child,
.ant-table-thead .ant-table-cell:first-child {
padding-left: 16px;

View File

@ -14,7 +14,7 @@
import { cleanup, render, screen } from '@testing-library/react';
import React from 'react';
import { TestCaseStatus } from '../../../generated/tests/tableTest';
import { TestIndicatorProps } from '../TableProfiler.interface';
import { TestIndicatorProps } from '../../TableProfiler/TableProfiler.interface';
import TestIndicator from './TestIndicator';
const mockProps: TestIndicatorProps = {

View File

@ -13,7 +13,8 @@
import classNames from 'classnames';
import React from 'react';
import { TestIndicatorProps } from '../TableProfiler.interface';
import { TestIndicatorProps } from '../../TableProfiler/TableProfiler.interface';
import './testIndicator.less';
const TestIndicator: React.FC<TestIndicatorProps> = ({ value, type }) => {
return (

View File

@ -0,0 +1,33 @@
/*
* Copyright 2022 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.
*/
@succesColor: #28a745;
@failedColor: #cb2431;
@abortedColor: #efae2f;
.test-indicator {
display: inline-block;
height: 8px;
width: 8px;
border-radius: 50%;
&.success {
background: @succesColor;
}
&.failed {
background: @failedColor;
}
&.aborted {
background: @abortedColor;
}
}

View File

@ -65,7 +65,7 @@ interface Props {
deleted?: boolean;
followers?: number;
extraInfo: Array<ExtraInfo>;
tier: TagLabel;
tier: TagLabel | undefined;
tags: Array<EntityTags>;
isTagEditable?: boolean;
owner?: EntityReference;

View File

@ -217,6 +217,7 @@ export const ROUTES = {
CUSTOM_PROPERTIES: `/custom-properties`,
CUSTOM_ENTITY_DETAIL: `/custom-properties/${PLACEHOLDER_ENTITY_TYPE_FQN}`,
ADD_CUSTOM_PROPERTY: `/custom-properties/${PLACEHOLDER_ENTITY_TYPE_FQN}/add-field`,
PROFILER_DASHBOARD: `/profiler-dashboard/${PLACEHOLDER_ENTITY_TYPE_FQN}`,
// Tasks Routes
REQUEST_DESCRIPTION: `/request-description/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_ENTITY_FQN}`,

View File

@ -11,61 +11,8 @@
* limitations under the License.
*/
import { CSMode } from '../enums/codemirror.enum';
import { ColumnProfilerConfig } from '../generated/entity/data/table';
import { JSON_TAB_SIZE } from './constants';
export const ENTITY_DELETE_STATE = {
loading: 'initial',
state: false,
softDelete: true,
};
export const PROFILER_METRIC = [
'valuesCount',
'valuesPercentage',
'validCount',
'duplicateCount',
'nullCount',
'nullProportion',
'missingPercentage',
'missingCount',
'uniqueCount',
'uniqueProportion',
'distinctCount',
'distinctProportion',
'min',
'max',
'minLength',
'maxLength',
'mean',
'sum',
'stddev',
'variance',
'median',
'histogram',
'customMetricsProfile',
];
export const DEFAULT_INCLUDE_PROFILE: ColumnProfilerConfig[] = [
{
columnName: undefined,
metrics: ['all'],
},
];
export const codeMirrorOption = {
tabSize: JSON_TAB_SIZE,
indentUnit: JSON_TAB_SIZE,
indentWithTabs: true,
lineNumbers: true,
lineWrapping: true,
styleActiveLine: true,
matchBrackets: true,
autoCloseBrackets: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
mode: {
name: CSMode.SQL,
},
};

View File

@ -11,6 +11,10 @@
* limitations under the License.
*/
import { CSMode } from '../enums/codemirror.enum';
import { ColumnProfilerConfig } from '../generated/entity/data/table';
import { JSON_TAB_SIZE } from './constants';
export const excludedMetrics = [
'profilDate',
'name',
@ -24,3 +28,151 @@ export const excludedMetrics = [
'missingPercentage',
'distinctProportion',
];
export const PROFILER_METRIC = [
'valuesCount',
'valuesPercentage',
'validCount',
'duplicateCount',
'nullCount',
'nullProportion',
'missingPercentage',
'missingCount',
'uniqueCount',
'uniqueProportion',
'distinctCount',
'distinctProportion',
'min',
'max',
'minLength',
'maxLength',
'mean',
'sum',
'stddev',
'variance',
'median',
'histogram',
'customMetricsProfile',
];
export const PROFILER_FILTER_RANGE = {
last3days: { days: 3, title: 'Last 3 days' },
last7days: { days: 7, title: 'Last 7 days' },
last14days: { days: 14, title: 'Last 14 days' },
last30days: { days: 30, title: 'Last 30 days' },
last60days: { days: 60, title: 'Last 60 days' },
};
export const DEFAULT_CHART_COLLECTION_VALUE = {
distinctCount: { data: [], color: '#1890FF' },
uniqueCount: { data: [], color: '#008376' },
nullCount: { data: [], color: '#7147E8' },
nullProportion: { data: [], color: '#B02AAC' },
};
export const INITIAL_COUNT_METRIC_VALUE = {
information: [
{
title: 'Distinct Count',
dataKey: 'distinctCount',
color: '#1890FF',
},
{
title: 'Null Count',
dataKey: 'nullCount',
color: '#7147E8',
},
{
title: 'Unique Count',
dataKey: 'uniqueCount',
color: '#008376',
},
{
title: 'Values Count',
dataKey: 'valuesCount',
color: '#B02AAC',
},
],
data: [],
};
export const INITIAL_PROPORTION_METRIC_VALUE = {
information: [
{
title: 'Distinct Proportion',
dataKey: 'distinctProportion',
color: '#1890FF',
},
{
title: 'Null Proportion',
dataKey: 'nullProportion',
color: '#7147E8',
},
{
title: 'Unique Proportion',
dataKey: 'uniqueProportion',
color: '#008376',
},
],
data: [],
};
export const INITIAL_MATH_METRIC_VALUE = {
information: [
{
title: 'Median',
dataKey: 'median',
color: '#1890FF',
},
{
title: 'Max',
dataKey: 'max',
color: '#7147E8',
},
{
title: 'Mean',
dataKey: 'mean',
color: '#008376',
},
{
title: 'Min',
dataKey: 'min',
color: '#B02AAC',
},
],
data: [],
};
export const INITIAL_SUM_METRIC_VALUE = {
information: [
{
title: 'Sum',
dataKey: 'sum',
color: '#1890FF',
},
],
data: [],
};
export const DEFAULT_INCLUDE_PROFILE: ColumnProfilerConfig[] = [
{
columnName: undefined,
metrics: ['all'],
},
];
export const codeMirrorOption = {
tabSize: JSON_TAB_SIZE,
indentUnit: JSON_TAB_SIZE,
indentWithTabs: true,
lineNumbers: true,
lineWrapping: true,
styleActiveLine: true,
matchBrackets: true,
autoCloseBrackets: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
mode: {
name: CSMode.SQL,
},
};

View File

@ -275,6 +275,18 @@ jest.mock('react-router-dom', () => ({
useParams: jest.fn().mockImplementation(() => mockUseParams),
}));
jest.mock('../../utils/CommonUtils', () => ({
addToRecentViewed: jest.fn(),
getCurrentUserId: jest.fn().mockReturnValue('test'),
getEntityMissingError: jest
.fn()
.mockImplementation(() => <span>Entity missing error</span>),
getEntityName: jest.fn().mockReturnValue('getEntityName'),
getFeedCounts: jest.fn(),
getFields: jest.fn().mockReturnValue('field'),
getPartialNameFromTableFQN: jest.fn().mockReturnValue('name'),
}));
describe('Test DatasetDetails page', () => {
it('Component should render properly', async () => {
const { container } = render(<DatasetDetailsPage />, {

View File

@ -0,0 +1,129 @@
/*
* Copyright 2022 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 { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import moment from 'moment';
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
getTableDetailsByFQN,
getTableProfilesList,
patchTableDetails,
} from '../../axiosAPIs/tableAPI';
import ErrorPlaceHolder from '../../components/common/error-with-placeholder/ErrorPlaceHolder';
import PageContainerV1 from '../../components/containers/PageContainerV1';
import Loader from '../../components/Loader/Loader';
import ProfilerDashboard from '../../components/ProfilerDashboard/ProfilerDashboard';
import { API_RES_MAX_SIZE } from '../../constants/constants';
import { Table, TableProfile } from '../../generated/entity/data/table';
import jsonData from '../../jsons/en';
import {
getNameFromFQN,
getTableFQNFromColumnFQN,
} from '../../utils/CommonUtils';
import { showErrorToast } from '../../utils/ToastUtils';
const ProfilerDashboardPage = () => {
const { entityTypeFQN } = useParams<Record<string, string>>();
const [table, setTable] = useState<Table>({} as Table);
const [profilerData, setProfilerData] = useState<TableProfile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
const fetchProfilerData = async (tableId: string, days = 3) => {
try {
const startTs = moment().subtract(days, 'days').unix();
const data = await getTableProfilesList(tableId, {
startTs: startTs,
limit: API_RES_MAX_SIZE,
});
setProfilerData(data.data || []);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsLoading(false);
}
};
const fetchTableEntity = async (fqn: string) => {
try {
getTableFQNFromColumnFQN(fqn);
const data = await getTableDetailsByFQN(
getTableFQNFromColumnFQN(fqn),
'tags, usageSummary, owner, followers'
);
setTable(data ?? ({} as Table));
fetchProfilerData(data.id);
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['fetch-table-details-error']
);
setIsLoading(false);
setError(true);
}
};
const updateTableHandler = async (updatedTable: Table) => {
const jsonPatch = compare(table, updatedTable);
try {
const tableRes = await patchTableDetails(table.id, jsonPatch);
setTable(tableRes);
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['update-entity-error']
);
}
};
useEffect(() => {
if (entityTypeFQN) {
fetchTableEntity(entityTypeFQN);
} else {
setIsLoading(false);
setError(true);
}
}, [entityTypeFQN]);
if (isLoading) {
return <Loader />;
}
if (error) {
return (
<ErrorPlaceHolder>
<p className="tw-text-center">
No data found{' '}
{entityTypeFQN ? `for column ${getNameFromFQN(entityTypeFQN)}` : ''}
</p>
</ErrorPlaceHolder>
);
}
return (
<PageContainerV1 className="tw-py-4">
<ProfilerDashboard
fetchProfilerData={fetchProfilerData}
profilerData={profilerData}
table={table}
onTableChange={updateTableHandler}
/>
</PageContainerV1>
);
};
export default ProfilerDashboardPage;

View File

@ -23,6 +23,12 @@ const GlobalSettingPage = withSuspenseFallback(
React.lazy(() => import('../pages/GlobalSettingPage/GlobalSettingPage'))
);
const ProfilerDashboardPage = withSuspenseFallback(
React.lazy(
() => import('../pages/ProfilerDashboardPage/ProfilerDashboardPage')
)
);
const MyDataPage = withSuspenseFallback(
React.lazy(() => import('../pages/MyDataPage/MyDataPage.component'))
);
@ -275,6 +281,11 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
<Route exact component={UserPage} path={ROUTES.USER_PROFILE} />
<Route exact component={UserPage} path={ROUTES.USER_PROFILE_WITH_TAB} />
<Route exact component={MlModelPage} path={ROUTES.MLMODEL_DETAILS} />
<Route
exact
component={ProfilerDashboardPage}
path={ROUTES.PROFILER_DASHBOARD}
/>
<Route
exact
component={MlModelPage}

View File

@ -152,14 +152,9 @@ export const getPartialNameFromTableFQN = (
};
export const getCurrentUserId = (): string => {
// TODO: Replace below with USERID from Logged-in data
const { id: userId } = !isEmpty(AppState.userDetails)
? AppState.userDetails
: AppState.users?.length
? AppState.users[0]
: { id: undefined };
const currentUser = AppState.getCurrentUserDetails();
return userId as string;
return currentUser?.id || '';
};
export const pluralize = (count: number, noun: string, suffix = 's') => {

View File

@ -14,6 +14,7 @@
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
import {
IN_PAGE_SEARCH_ROUTES,
PLACEHOLDER_ENTITY_TYPE_FQN,
PLACEHOLDER_GLOSSARY_NAME,
PLACEHOLDER_GLOSSARY_TERMS_FQN,
PLACEHOLDER_ROUTE_FQN,
@ -292,3 +293,11 @@ export const getPath = (pathName: string) => {
return getSettingPath();
}
};
export const getProfilerDashboardWithFqnPath = (entityTypeFQN: string) => {
let path = ROUTES.PROFILER_DASHBOARD;
path = path.replace(PLACEHOLDER_ENTITY_TYPE_FQN, entityTypeFQN);
return path;
};