refactor(ui): Misc improvements to Dataset Assertions UI (#5155)

This commit is contained in:
John Joyce 2022-06-14 16:02:15 -04:00 committed by GitHub
parent 76d35dcffe
commit 7c46437280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 94 additions and 27 deletions

View File

@ -28,7 +28,6 @@ import com.linkedin.timeseries.GroupingBucketType;
import graphql.schema.DataFetcher; import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@ -122,7 +121,14 @@ public class DatasetHealthResolver implements DataFetcher<CompletableFuture<Heal
.stream() .stream()
.map(relationship -> relationship.getEntity().toString()).collect(Collectors.toSet()); .map(relationship -> relationship.getEntity().toString()).collect(Collectors.toSet());
final List<String> failingAssertionUrns = getFailingAssertionUrns(datasetUrn, activeAssertionUrns); final GenericTable assertionRunResults = getAssertionRunsTable(datasetUrn);
if (!assertionRunResults.hasRows() || assertionRunResults.getRows().size() == 0) {
// No assertion run results found. Return empty health!
return null;
}
final List<String> failingAssertionUrns = getFailingAssertionUrns(assertionRunResults, activeAssertionUrns);
// Finally compute & return the health. // Finally compute & return the health.
final Health health = new Health(); final Health health = new Health();
@ -141,20 +147,18 @@ public class DatasetHealthResolver implements DataFetcher<CompletableFuture<Heal
return null; return null;
} }
private List<String> getFailingAssertionUrns(final String asserteeUrn, final Set<String> candidateAssertionUrns) { private GenericTable getAssertionRunsTable(final String asserteeUrn) {
// Query timeseries backend return _timeseriesAspectService.getAggregatedStats(
GenericTable result = _timeseriesAspectService.getAggregatedStats(
Constants.ASSERTION_ENTITY_NAME, Constants.ASSERTION_ENTITY_NAME,
Constants.ASSERTION_RUN_EVENT_ASPECT_NAME, Constants.ASSERTION_RUN_EVENT_ASPECT_NAME,
createAssertionAggregationSpecs(), createAssertionAggregationSpecs(),
createAssertionsFilter(asserteeUrn), createAssertionsFilter(asserteeUrn),
createAssertionGroupingBuckets()); createAssertionGroupingBuckets());
if (!result.hasRows()) { }
// No completed assertion runs found. Return empty list.
return Collections.emptyList(); private List<String> getFailingAssertionUrns(final GenericTable assertionRunsResult, final Set<String> candidateAssertionUrns) {
}
// Create the buckets based on the result // Create the buckets based on the result
return resultToFailedAssertionUrns(result.getRows(), candidateAssertionUrns); return resultToFailedAssertionUrns(assertionRunsResult.getRows(), candidateAssertionUrns);
} }
private Filter createAssertionsFilter(final String datasetUrn) { private Filter createAssertionsFilter(final String datasetUrn) {

View File

@ -1,8 +1,16 @@
import React from 'react'; import React from 'react';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import styled from 'styled-components';
import { getHealthIcon } from '../../../../../shared/health/healthUtils'; import { getHealthIcon } from '../../../../../shared/health/healthUtils';
import { HealthStatus } from '../../../../../../types.generated'; import { HealthStatus } from '../../../../../../types.generated';
const StatusContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
margin-left: 8px;
`;
type Props = { type Props = {
status: HealthStatus; status: HealthStatus;
message?: string | undefined; message?: string | undefined;
@ -11,8 +19,8 @@ type Props = {
export const EntityHealthStatus = ({ status, message }: Props) => { export const EntityHealthStatus = ({ status, message }: Props) => {
const icon = getHealthIcon(status, 18); const icon = getHealthIcon(status, 18);
return ( return (
<div style={{ paddingLeft: 12, paddingRight: 12, paddingTop: 4, paddingBottom: 4 }}> <StatusContainer>
<Tooltip title={message}>{icon}</Tooltip> <Tooltip title={message}>{icon}</Tooltip>
</div> </StatusContainer>
); );
}; };

View File

@ -1,5 +1,5 @@
import { Popover, Typography } from 'antd'; import { Popover, Typography, Button } from 'antd';
import React from 'react'; import React, { useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
AssertionStdAggregation, AssertionStdAggregation,
@ -10,10 +10,11 @@ import {
SchemaFieldRef, SchemaFieldRef,
} from '../../../../../../types.generated'; } from '../../../../../../types.generated';
import { getFormattedParameterValue } from './assertionUtils'; import { getFormattedParameterValue } from './assertionUtils';
import { DatasetAssertionLogicModal } from './DatasetAssertionLogicModal';
const SqlText = styled.pre` const ViewLogicButton = styled(Button)`
margin: 0px;
padding: 0px; padding: 0px;
margin: 0px;
`; `;
type Props = { type Props = {
@ -314,7 +315,7 @@ const TOOLTIP_MAX_WIDTH = 440;
*/ */
export const DatasetAssertionDescription = ({ assertionInfo }: Props) => { export const DatasetAssertionDescription = ({ assertionInfo }: Props) => {
const { scope, aggregation, fields, operator, parameters, nativeType, nativeParameters, logic } = assertionInfo; const { scope, aggregation, fields, operator, parameters, nativeType, nativeParameters, logic } = assertionInfo;
const [isLogicVisible, setIsLogicVisible] = useState(false);
/** /**
* Build a description component from a) input (aggregation, inputs) b) the operator text * Build a description component from a) input (aggregation, inputs) b) the operator text
*/ */
@ -342,7 +343,19 @@ export const DatasetAssertionDescription = ({ assertionInfo }: Props) => {
</> </>
} }
> >
<div>{(logic && <SqlText>{logic}</SqlText>) || description}</div> <div>{description}</div>
{logic && (
<div>
<ViewLogicButton onClick={() => setIsLogicVisible(true)} type="link">
View Logic
</ViewLogicButton>
</div>
)}
<DatasetAssertionLogicModal
logic={logic || 'N/A'}
visible={isLogicVisible}
onClose={() => setIsLogicVisible(false)}
/>
</Popover> </Popover>
); );
}; };

View File

@ -0,0 +1,24 @@
import { Modal, Button } from 'antd';
import React from 'react';
import Query from '../Queries/Query';
export type AssertionsSummary = {
totalAssertions: number;
totalRuns: number;
failedRuns: number;
succeededRuns: number;
};
type Props = {
logic: string;
visible: boolean;
onClose: () => void;
};
export const DatasetAssertionLogicModal = ({ logic, visible, onClose }: Props) => {
return (
<Modal visible={visible} onCancel={onClose} footer={<Button onClick={onClose}>Close</Button>}>
<Query query={logic} />
</Modal>
);
};

View File

@ -6,9 +6,9 @@ import { ANTD_GRAY } from '../../../constants';
const SummaryHeader = styled.div` const SummaryHeader = styled.div`
width: 100%; width: 100%;
height: 80px;
padding-left: 40px; padding-left: 40px;
padding-top: 0px; padding-top: 20px;
padding-bottom: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
border-bottom: 1px solid ${ANTD_GRAY[4.5]}; border-bottom: 1px solid ${ANTD_GRAY[4.5]};

View File

@ -47,23 +47,28 @@ enum ViewType {
export const ValidationsTab = () => { export const ValidationsTab = () => {
const { urn, entityData } = useEntityData(); const { urn, entityData } = useEntityData();
const { data, refetch } = useGetDatasetAssertionsQuery({ variables: { urn } }); const { data, refetch } = useGetDatasetAssertionsQuery({ variables: { urn } });
const [removedUrns, setRemovedUrns] = useState<string[]>([]);
/** /**
* Determines which view should be visible: assertions or tests. * Determines which view should be visible: assertions or tests.
*/ */
const [view, setView] = useState(ViewType.ASSERTIONS); const [view, setView] = useState(ViewType.ASSERTIONS);
const assertions = (data && data.dataset?.assertions?.assertions?.map((assertion) => assertion as Assertion)) || []; const assertions = (data && data.dataset?.assertions?.assertions?.map((assertion) => assertion as Assertion)) || [];
const totalAssertions = data?.dataset?.assertions?.total || 0; const maybeTotalAssertions = data?.dataset?.assertions?.total || undefined;
const effectiveTotalAssertions = maybeTotalAssertions || 0;
const filteredAssertions = assertions.filter((assertion) => !removedUrns.includes(assertion.urn));
const passingTests = (entityData as any)?.testResults?.passing || []; const passingTests = (entityData as any)?.testResults?.passing || [];
const maybeFailingTests = (entityData as any)?.testResults?.failing || []; const maybeFailingTests = (entityData as any)?.testResults?.failing || [];
const totalTests = maybeFailingTests.length + passingTests.length; const totalTests = maybeFailingTests.length + passingTests.length;
useEffect(() => { useEffect(() => {
if (totalAssertions === 0) { if (totalTests > 0 && maybeTotalAssertions === 0) {
setView(ViewType.TESTS); setView(ViewType.TESTS);
} else {
setView(ViewType.ASSERTIONS);
} }
}, [totalAssertions]); }, [totalTests, maybeTotalAssertions]);
// Pre-sort the list of assertions based on which has been most recently executed. // Pre-sort the list of assertions based on which has been most recently executed.
assertions.sort(sortAssertions); assertions.sort(sortAssertions);
@ -72,9 +77,13 @@ export const ValidationsTab = () => {
<> <>
<TabToolbar> <TabToolbar>
<div> <div>
<Button type="text" disabled={totalAssertions === 0} onClick={() => setView(ViewType.ASSERTIONS)}> <Button
type="text"
disabled={effectiveTotalAssertions === 0}
onClick={() => setView(ViewType.ASSERTIONS)}
>
<FileProtectOutlined /> <FileProtectOutlined />
Assertions ({totalAssertions}) Assertions ({effectiveTotalAssertions})
</Button> </Button>
<Button type="text" disabled={totalTests === 0} onClick={() => setView(ViewType.TESTS)}> <Button type="text" disabled={totalTests === 0} onClick={() => setView(ViewType.TESTS)}>
<FileDoneOutlined /> <FileDoneOutlined />
@ -84,8 +93,17 @@ export const ValidationsTab = () => {
</TabToolbar> </TabToolbar>
{(view === ViewType.ASSERTIONS && ( {(view === ViewType.ASSERTIONS && (
<> <>
<DatasetAssertionsSummary summary={getAssertionsStatusSummary(assertions)} /> <DatasetAssertionsSummary summary={getAssertionsStatusSummary(filteredAssertions)} />
{entityData && <DatasetAssertionsList assertions={assertions} onDelete={() => refetch()} />} {entityData && (
<DatasetAssertionsList
assertions={filteredAssertions}
onDelete={(assertionUrn) => {
// Hack to deal with eventual consistency.
setRemovedUrns([...removedUrns, assertionUrn]);
setTimeout(() => refetch(), 3000);
}}
/>
)}
</> </>
)) || <TestResults passing={passingTests} failing={maybeFailingTests} />} )) || <TestResults passing={passingTests} failing={maybeFailingTests} />}
</> </>