diff --git a/datahub-graphql-core/build.gradle b/datahub-graphql-core/build.gradle index de264ce31b719b..49a7fa7fbfbc2f 100644 --- a/datahub-graphql-core/build.gradle +++ b/datahub-graphql-core/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation externalDependency.opentelemetryAnnotations implementation externalDependency.slf4jApi + implementation externalDependency.springContext compileOnly externalDependency.lombok annotationProcessor externalDependency.lombok diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolver.java index 77fabd7167300e..12266db05b6d10 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolver.java @@ -25,12 +25,16 @@ import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import java.net.URISyntaxException; +import java.time.DateTimeException; +import java.time.ZoneId; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.support.CronExpression; /** Creates or updates an ingestion source. Requires the MANAGE_INGESTION privilege. */ @Slf4j @@ -46,55 +50,51 @@ public UpsertIngestionSourceResolver(final EntityClient entityClient) { public CompletableFuture get(final DataFetchingEnvironment environment) throws Exception { final QueryContext context = environment.getContext(); - return GraphQLConcurrencyUtils.supplyAsync( - () -> { - if (IngestionAuthUtils.canManageIngestion(context)) { - - final Optional ingestionSourceUrn = - Optional.ofNullable(environment.getArgument("urn")); - final UpdateIngestionSourceInput input = - bindArgument(environment.getArgument("input"), UpdateIngestionSourceInput.class); + if (!IngestionAuthUtils.canManageIngestion(context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + final Optional ingestionSourceUrn = Optional.ofNullable(environment.getArgument("urn")); + final UpdateIngestionSourceInput input = + bindArgument(environment.getArgument("input"), UpdateIngestionSourceInput.class); - // Create the policy info. - final DataHubIngestionSourceInfo info = mapIngestionSourceInfo(input); - final MetadataChangeProposal proposal; - if (ingestionSourceUrn.isPresent()) { - // Update existing ingestion source - try { - proposal = - buildMetadataChangeProposalWithUrn( - Urn.createFromString(ingestionSourceUrn.get()), - INGESTION_INFO_ASPECT_NAME, - info); - } catch (URISyntaxException e) { - throw new DataHubGraphQLException( - String.format("Malformed urn %s provided.", ingestionSourceUrn.get()), - DataHubGraphQLErrorCode.BAD_REQUEST); - } - } else { - // Create new ingestion source - // Since we are creating a new Ingestion Source, we need to generate a unique UUID. - final UUID uuid = UUID.randomUUID(); - final String uuidStr = uuid.toString(); - final DataHubIngestionSourceKey key = new DataHubIngestionSourceKey(); - key.setId(uuidStr); - proposal = - buildMetadataChangeProposalWithKey( - key, INGESTION_SOURCE_ENTITY_NAME, INGESTION_INFO_ASPECT_NAME, info); - } + // Create the policy info. + final DataHubIngestionSourceInfo info = mapIngestionSourceInfo(input); + final MetadataChangeProposal proposal; + if (ingestionSourceUrn.isPresent()) { + // Update existing ingestion source + try { + proposal = + buildMetadataChangeProposalWithUrn( + Urn.createFromString(ingestionSourceUrn.get()), INGESTION_INFO_ASPECT_NAME, info); + } catch (URISyntaxException e) { + throw new DataHubGraphQLException( + String.format("Malformed urn %s provided.", ingestionSourceUrn.get()), + DataHubGraphQLErrorCode.BAD_REQUEST); + } + } else { + // Create new ingestion source + // Since we are creating a new Ingestion Source, we need to generate a unique UUID. + final UUID uuid = UUID.randomUUID(); + final String uuidStr = uuid.toString(); + final DataHubIngestionSourceKey key = new DataHubIngestionSourceKey(); + key.setId(uuidStr); + proposal = + buildMetadataChangeProposalWithKey( + key, INGESTION_SOURCE_ENTITY_NAME, INGESTION_INFO_ASPECT_NAME, info); + } - try { - return _entityClient.ingestProposal(context.getOperationContext(), proposal, false); - } catch (Exception e) { - throw new RuntimeException( - String.format( - "Failed to perform update against ingestion source with urn %s", - input.toString()), - e); - } + return GraphQLConcurrencyUtils.supplyAsync( + () -> { + try { + return _entityClient.ingestProposal(context.getOperationContext(), proposal, false); + } catch (Exception e) { + throw new RuntimeException( + String.format( + "Failed to perform update against ingestion source with urn %s", + input.toString()), + e); } - throw new AuthorizationException( - "Unauthorized to perform this action. Please contact your DataHub administrator."); }, this.getClass().getSimpleName(), "get"); @@ -137,9 +137,38 @@ private DataHubIngestionSourceConfig mapConfig(final UpdateIngestionSourceConfig private DataHubIngestionSourceSchedule mapSchedule( final UpdateIngestionSourceScheduleInput input) { + + final String modifiedCronInterval = adjustCronInterval(input.getInterval()); + try { + CronExpression.parse(modifiedCronInterval); + } catch (IllegalArgumentException e) { + throw new DataHubGraphQLException( + String.format("Invalid cron schedule `%s`: %s", input.getInterval(), e.getMessage()), + DataHubGraphQLErrorCode.BAD_REQUEST); + } + try { + ZoneId.of(input.getTimezone()); + } catch (DateTimeException e) { + throw new DataHubGraphQLException( + String.format("Invalid timezone `%s`: %s", input.getTimezone(), e.getMessage()), + DataHubGraphQLErrorCode.BAD_REQUEST); + } + final DataHubIngestionSourceSchedule result = new DataHubIngestionSourceSchedule(); result.setInterval(input.getInterval()); result.setTimezone(input.getTimezone()); return result; } + + // Copied from IngestionScheduler.java + private String adjustCronInterval(final String origCronInterval) { + Objects.requireNonNull(origCronInterval, "origCronInterval must not be null"); + // Typically we support 5-character cron. Spring's lib only supports 6 character cron so we make + // an adjustment here. + final String[] originalCronParts = origCronInterval.split(" "); + if (originalCronParts.length == 5) { + return String.format("0 %s", origCronInterval); + } + return origCronInterval; + } } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolverTest.java index b453958e7af474..57d96030a32aaa 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/ingest/source/UpsertIngestionSourceResolverTest.java @@ -7,6 +7,7 @@ import static org.testng.Assert.*; import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; import com.linkedin.datahub.graphql.generated.UpdateIngestionSourceConfigInput; import com.linkedin.datahub.graphql.generated.UpdateIngestionSourceInput; import com.linkedin.datahub.graphql.generated.UpdateIngestionSourceScheduleInput; @@ -22,14 +23,17 @@ public class UpsertIngestionSourceResolverTest { - private static final UpdateIngestionSourceInput TEST_INPUT = - new UpdateIngestionSourceInput( - "Test source", - "mysql", - "Test source description", - new UpdateIngestionSourceScheduleInput("* * * * *", "UTC"), - new UpdateIngestionSourceConfigInput( - "my test recipe", "0.8.18", "executor id", false, null)); + private static final UpdateIngestionSourceInput TEST_INPUT = makeInput(); + + private static UpdateIngestionSourceInput makeInput() { + return new UpdateIngestionSourceInput( + "Test source", + "mysql", + "Test source description", + new UpdateIngestionSourceScheduleInput("* * * * *", "UTC"), + new UpdateIngestionSourceConfigInput( + "my test recipe", "0.8.18", "executor id", false, null)); + } @Test public void testGetSuccess() throws Exception { @@ -104,4 +108,54 @@ public void testGetEntityClientException() throws Exception { assertThrows(RuntimeException.class, () -> resolver.get(mockEnv).join()); } + + @Test + public void testUpsertWithInvalidCron() throws Exception { + final UpdateIngestionSourceInput input = makeInput(); + input.setSchedule(new UpdateIngestionSourceScheduleInput("* * * * 123", "UTC")); + + // Create resolver + EntityClient mockClient = Mockito.mock(EntityClient.class); + UpsertIngestionSourceResolver resolver = new UpsertIngestionSourceResolver(mockClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))) + .thenReturn(TEST_INGESTION_SOURCE_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(DataHubGraphQLException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(any(), any(), anyBoolean()); + + input.setSchedule(new UpdateIngestionSourceScheduleInput("null", "UTC")); + assertThrows(DataHubGraphQLException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(any(), any(), anyBoolean()); + } + + @Test + public void testUpsertWithInvalidTimezone() throws Exception { + final UpdateIngestionSourceInput input = makeInput(); + input.setSchedule(new UpdateIngestionSourceScheduleInput("* * * * *", "Invalid")); + + // Create resolver + EntityClient mockClient = Mockito.mock(EntityClient.class); + UpsertIngestionSourceResolver resolver = new UpsertIngestionSourceResolver(mockClient); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("urn"))) + .thenReturn(TEST_INGESTION_SOURCE_URN.toString()); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(input); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + assertThrows(DataHubGraphQLException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(any(), any(), anyBoolean()); + + input.setSchedule(new UpdateIngestionSourceScheduleInput("* * * * *", "America/Los_Angel")); + assertThrows(DataHubGraphQLException.class, () -> resolver.get(mockEnv).join()); + Mockito.verify(mockClient, Mockito.times(0)).ingestProposal(any(), any(), anyBoolean()); + } } diff --git a/datahub-web-react/src/app/ProtectedRoutes.tsx b/datahub-web-react/src/app/ProtectedRoutes.tsx index 0e4a1a260f5532..d975e6d4d99c2d 100644 --- a/datahub-web-react/src/app/ProtectedRoutes.tsx +++ b/datahub-web-react/src/app/ProtectedRoutes.tsx @@ -1,15 +1,26 @@ -import React from 'react'; -import { Switch, Route } from 'react-router-dom'; +import React, { useEffect } from 'react'; +import { Switch, Route, useLocation, useHistory } from 'react-router-dom'; import { Layout } from 'antd'; import { HomePage } from './home/HomePage'; import { SearchRoutes } from './SearchRoutes'; import EmbedRoutes from './EmbedRoutes'; -import { PageRoutes } from '../conf/Global'; +import { NEW_ROUTE_MAP, PageRoutes } from '../conf/Global'; +import { getRedirectUrl } from '../conf/utils'; /** * Container for all views behind an authentication wall. */ export const ProtectedRoutes = (): JSX.Element => { + const location = useLocation(); + const history = useHistory(); + + useEffect(() => { + if (location.pathname.indexOf('/Validation') !== -1) { + history.replace(getRedirectUrl(NEW_ROUTE_MAP)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location]); + return ( diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index 6074bcc2f2f406..c30fee7abc0b6d 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -36,6 +36,7 @@ import AccessManagement from '../shared/tabs/Dataset/AccessManagement/AccessMana import { matchedFieldPathsRenderer } from '../../search/matches/matchedFieldPathsRenderer'; import { getLastUpdatedMs } from './shared/utils'; import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; +import { GovernanceTab } from '../shared/tabs/Dataset/Governance/GovernanceTab'; const SUBTYPES = { VIEW: 'view', @@ -166,14 +167,22 @@ export class DatasetEntity implements Entity { }, }, { - name: 'Validation', + name: 'Quality', component: ValidationsTab, display: { visible: (_, _1) => true, enabled: (_, dataset: GetDatasetQuery) => { - return ( - (dataset?.dataset?.assertions?.total || 0) > 0 || dataset?.dataset?.testResults !== null - ); + return (dataset?.dataset?.assertions?.total || 0) > 0; + }, + }, + }, + { + name: 'Governance', + component: GovernanceTab, + display: { + visible: (_, _1) => true, + enabled: (_, dataset: GetDatasetQuery) => { + return dataset?.dataset?.testResults !== null; }, }, }, diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealth.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealth.tsx index 30713afa888b84..ad14d92a3915ae 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealth.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealth.tsx @@ -24,7 +24,7 @@ export const EntityHealth = ({ health, baseUrl, fontSize, tooltipPlacement }: Pr return ( <> {(unhealthy && ( - + {icon} diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/GovernanceTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/GovernanceTab.tsx new file mode 100644 index 00000000000000..213716ed501c5b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/GovernanceTab.tsx @@ -0,0 +1,85 @@ +import React, { useEffect } from 'react'; +import { Button } from 'antd'; +import { useHistory, useLocation } from 'react-router'; +import styled from 'styled-components'; +import { FileDoneOutlined } from '@ant-design/icons'; +import { useEntityData } from '../../../EntityContext'; +import { TestResults } from './TestResults'; +import TabToolbar from '../../../components/styled/TabToolbar'; +import { ANTD_GRAY } from '../../../constants'; +import { useGetValidationsTab } from '../Validations/useGetValidationsTab'; + +const TabTitle = styled.span` + margin-left: 4px; +`; + +const TabButton = styled(Button)<{ selected: boolean }>` + background-color: ${(props) => (props.selected && ANTD_GRAY[3]) || 'none'}; + margin-left: 4px; +`; + +enum TabPaths { + TESTS = 'Tests', +} + +const DEFAULT_TAB = TabPaths.TESTS; + +/** + * Component used for rendering the Entity Governance Tab. + */ +export const GovernanceTab = () => { + const { entityData } = useEntityData(); + const history = useHistory(); + const { pathname } = useLocation(); + + const passingTests = (entityData as any)?.testResults?.passing || []; + const maybeFailingTests = (entityData as any)?.testResults?.failing || []; + const totalTests = maybeFailingTests.length + passingTests.length; + + const { selectedTab, basePath } = useGetValidationsTab(pathname, Object.values(TabPaths)); + + // If no tab was selected, select a default tab. + useEffect(() => { + if (!selectedTab) { + // Route to the default tab. + history.replace(`${basePath}/${DEFAULT_TAB}`); + } + }, [selectedTab, basePath, history]); + + /** + * The top-level Toolbar tabs to display. + */ + const tabs = [ + { + title: ( + <> + + Tests ({totalTests}) + + ), + path: TabPaths.TESTS, + disabled: totalTests === 0, + content: , + }, + ]; + + return ( + <> + +
+ {tabs.map((tab) => ( + history.replace(`${basePath}/${tab.path}`)} + > + {tab.title} + + ))} +
+
+ {tabs.filter((tab) => tab.path === selectedTab).map((tab) => tab.content)} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResults.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResults.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResults.tsx rename to datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResults.tsx diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResultsList.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResultsList.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResultsList.tsx rename to datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResultsList.tsx diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResultsSummary.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResultsSummary.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/TestResultsSummary.tsx rename to datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/TestResultsSummary.tsx diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/testUtils.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/testUtils.tsx similarity index 100% rename from datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/testUtils.tsx rename to datahub-web-react/src/app/entity/shared/tabs/Dataset/Governance/testUtils.tsx diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx index 4d0e475d5dad14..bfcb30b6c5e7ac 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/DatasetAssertionsList.tsx @@ -188,7 +188,7 @@ export const DatasetAssertionsList = ({ to={`${entityRegistry.getEntityUrl( EntityType.Dataset, entityData.urn, - )}/Validation/Data Contract`} + )}/Quality/Data Contract`} style={{ color: REDESIGN_COLORS.BLUE }} > view @@ -200,7 +200,7 @@ export const DatasetAssertionsList = ({ to={`${entityRegistry.getEntityUrl( EntityType.Dataset, entityData.urn, - )}/Validation/Data Contract`} + )}/Quality/Data Contract`} > diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx index 92af9bfc2b567b..006823db53fd44 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/ValidationsTab.tsx @@ -2,9 +2,8 @@ import React, { useEffect } from 'react'; import { Button } from 'antd'; import { useHistory, useLocation } from 'react-router'; import styled from 'styled-components'; -import { AuditOutlined, FileDoneOutlined, FileProtectOutlined } from '@ant-design/icons'; +import { AuditOutlined, FileProtectOutlined } from '@ant-design/icons'; import { useEntityData } from '../../../EntityContext'; -import { TestResults } from './TestResults'; import { Assertions } from './Assertions'; import TabToolbar from '../../../components/styled/TabToolbar'; import { useGetValidationsTab } from './useGetValidationsTab'; @@ -22,8 +21,7 @@ const TabButton = styled(Button)<{ selected: boolean }>` `; enum TabPaths { - ASSERTIONS = 'Assertions', - TESTS = 'Tests', + ASSERTIONS = 'List', DATA_CONTRACT = 'Data Contract', } @@ -39,9 +37,6 @@ export const ValidationsTab = () => { const appConfig = useAppConfig(); const totalAssertions = (entityData as any)?.assertions?.total; - const passingTests = (entityData as any)?.testResults?.passing || []; - const maybeFailingTests = (entityData as any)?.testResults?.failing || []; - const totalTests = maybeFailingTests.length + passingTests.length; const { selectedTab, basePath } = useGetValidationsTab(pathname, Object.values(TabPaths)); @@ -68,17 +63,6 @@ export const ValidationsTab = () => { disabled: totalAssertions === 0, content: , }, - { - title: ( - <> - - Tests ({totalTests}) - - ), - path: TabPaths.TESTS, - disabled: totalTests === 0, - content: , - }, ]; if (appConfig.config.featureFlags?.dataContractsEnabled) { diff --git a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/__tests__/useGetValidationsTab.test.ts b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/__tests__/useGetValidationsTab.test.ts index 52689a225eae15..f65c337215ed90 100644 --- a/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/__tests__/useGetValidationsTab.test.ts +++ b/datahub-web-react/src/app/entity/shared/tabs/Dataset/Validations/__tests__/useGetValidationsTab.test.ts @@ -2,37 +2,37 @@ import { useGetValidationsTab } from '../useGetValidationsTab'; describe('useGetValidationsTab', () => { it('should correctly extract valid tab', () => { - const pathname = '/dataset/urn:li:abc/Validation/Assertions'; - const tabNames = ['Assertions']; + const pathname = '/dataset/urn:li:abc/Quality/List'; + const tabNames = ['List']; const res = useGetValidationsTab(pathname, tabNames); - expect(res.selectedTab).toEqual('Assertions'); - expect(res.basePath).toEqual('/dataset/urn:li:abc/Validation'); + expect(res.selectedTab).toEqual('List'); + expect(res.basePath).toEqual('/dataset/urn:li:abc/Quality'); }); it('should extract undefined for invalid tab', () => { - const pathname = '/dataset/urn:li:abc/Validation/Assertions'; + const pathname = '/dataset/urn:li:abc/Quality/Assertions'; const tabNames = ['Tests']; const res = useGetValidationsTab(pathname, tabNames); expect(res.selectedTab).toBeUndefined(); - expect(res.basePath).toEqual('/dataset/urn:li:abc/Validation'); + expect(res.basePath).toEqual('/dataset/urn:li:abc/Quality'); }); it('should extract undefined for missing tab', () => { - const pathname = '/dataset/urn:li:abc/Validation'; + const pathname = '/dataset/urn:li:abc/Quality'; const tabNames = ['Tests']; const res = useGetValidationsTab(pathname, tabNames); expect(res.selectedTab).toBeUndefined(); - expect(res.basePath).toEqual('/dataset/urn:li:abc/Validation'); + expect(res.basePath).toEqual('/dataset/urn:li:abc/Quality'); }); it('should handle trailing slashes', () => { - let pathname = '/dataset/urn:li:abc/Validation/Assertions/'; + let pathname = '/dataset/urn:li:abc/Quality/Assertions/'; let tabNames = ['Assertions']; let res = useGetValidationsTab(pathname, tabNames); expect(res.selectedTab).toEqual('Assertions'); - expect(res.basePath).toEqual('/dataset/urn:li:abc/Validation'); + expect(res.basePath).toEqual('/dataset/urn:li:abc/Quality'); - pathname = '/dataset/urn:li:abc/Validation/'; + pathname = '/dataset/urn:li:abc/Quality/'; tabNames = ['Assertions']; res = useGetValidationsTab(pathname, tabNames); expect(res.selectedTab).toBeUndefined(); - expect(res.basePath).toEqual('/dataset/urn:li:abc/Validation'); + expect(res.basePath).toEqual('/dataset/urn:li:abc/Quality'); }); }); diff --git a/datahub-web-react/src/app/shared/health/healthUtils.tsx b/datahub-web-react/src/app/shared/health/healthUtils.tsx index a3745da26ceea6..426e5d986ca55c 100644 --- a/datahub-web-react/src/app/shared/health/healthUtils.tsx +++ b/datahub-web-react/src/app/shared/health/healthUtils.tsx @@ -145,7 +145,7 @@ export const getHealthIcon = (type: HealthStatusType, status: HealthStatus, font export const getHealthRedirectPath = (type: HealthStatusType) => { switch (type) { case HealthStatusType.Assertions: { - return 'Validation/Assertions'; + return 'Quality/List'; } case HealthStatusType.Incidents: { return 'Incidents'; diff --git a/datahub-web-react/src/conf/Global.ts b/datahub-web-react/src/conf/Global.ts index 433b0b6416e780..e0172d3f07e41e 100644 --- a/datahub-web-react/src/conf/Global.ts +++ b/datahub-web-react/src/conf/Global.ts @@ -41,3 +41,11 @@ export const CLIENT_AUTH_COOKIE = 'actor'; * Name of the unique browser id cookie generated on client side */ export const BROWSER_ID_COOKIE = 'bid'; + +/** New Routes Map for redirection */ +export const NEW_ROUTE_MAP = { + '/Validation/Assertions': '/Quality/List', + '/Validation/Tests': '/Governance/Tests', + '/Validation/Data%20Contract': '/Quality/Data%20Contract', + '/Validation': '/Quality', +}; diff --git a/datahub-web-react/src/conf/utils.ts b/datahub-web-react/src/conf/utils.ts new file mode 100644 index 00000000000000..7adb82cf71be52 --- /dev/null +++ b/datahub-web-react/src/conf/utils.ts @@ -0,0 +1,26 @@ +/** + * + * as per the new route object + * We are redirecting older routes to new one + * e.g. + * { + '/Validation/Assertions': '/Quality/List', + } + * */ + +export const getRedirectUrl = (newRoutes: { [key: string]: string }) => { + let newPathname = `${window.location.pathname}${window.location.search}`; + if (!newRoutes) { + return newPathname; + } + + // eslint-disable-next-line no-restricted-syntax + for (const path of Object.keys(newRoutes)) { + if (newPathname.indexOf(path) !== -1) { + newPathname = newPathname.replace(path, newRoutes[path]); + break; + } + } + + return `${newPathname}${window.location.search}`; +}; diff --git a/metadata-ingestion/src/datahub/cli/ingest_cli.py b/metadata-ingestion/src/datahub/cli/ingest_cli.py index 6c8cc6beafbbf1..0f1603255c29f0 100644 --- a/metadata-ingestion/src/datahub/cli/ingest_cli.py +++ b/metadata-ingestion/src/datahub/cli/ingest_cli.py @@ -16,7 +16,7 @@ import datahub as datahub_package from datahub.cli import cli_utils from datahub.cli.config_utils import CONDENSED_DATAHUB_CONFIG_PATH -from datahub.configuration.common import ConfigModel +from datahub.configuration.common import ConfigModel, GraphError from datahub.configuration.config_loader import load_config_file from datahub.emitter.mce_builder import datahub_guid from datahub.ingestion.graph.client import get_default_graph @@ -372,7 +372,19 @@ def deploy( """ ) - response = datahub_graph.execute_graphql(graphql_query, variables=variables) + try: + response = datahub_graph.execute_graphql( + graphql_query, variables=variables, format_exception=False + ) + except GraphError as graph_error: + try: + error = json.loads(str(graph_error).replace('"', '\\"').replace("'", '"')) + click.secho(error[0]["message"], fg="red", err=True) + except Exception: + click.secho( + f"Could not create ingestion source:\n{graph_error}", fg="red", err=True + ) + sys.exit(1) click.echo( f"✅ Successfully wrote data ingestion source metadata for recipe {deploy_options.name}:" diff --git a/metadata-ingestion/src/datahub/ingestion/graph/client.py b/metadata-ingestion/src/datahub/ingestion/graph/client.py index 4d7b58d185d756..2cd22f8a502d03 100644 --- a/metadata-ingestion/src/datahub/ingestion/graph/client.py +++ b/metadata-ingestion/src/datahub/ingestion/graph/client.py @@ -1111,6 +1111,7 @@ def execute_graphql( query: str, variables: Optional[Dict] = None, operation_name: Optional[str] = None, + format_exception: bool = True, ) -> Dict: url = f"{self.config.server}/api/graphql" @@ -1127,7 +1128,10 @@ def execute_graphql( ) result = self._post_generic(url, body) if result.get("errors"): - raise GraphError(f"Error executing graphql query: {result['errors']}") + if format_exception: + raise GraphError(f"Error executing graphql query: {result['errors']}") + else: + raise GraphError(result["errors"]) return result["data"] diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeline/data/entity/OwnerChangeEvent.java b/metadata-io/src/main/java/com/linkedin/metadata/timeline/data/entity/OwnerChangeEvent.java index fc4f0327b77042..a14b73d8f94b0b 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeline/data/entity/OwnerChangeEvent.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeline/data/entity/OwnerChangeEvent.java @@ -27,17 +27,29 @@ public OwnerChangeEvent( SemanticChangeType semVerChange, String description, Urn ownerUrn, - OwnershipType ownerType) { + OwnershipType ownerType, + Urn ownerTypeUrn) { super( entityUrn, category, operation, modifier, - ImmutableMap.of( - "ownerUrn", ownerUrn.toString(), - "ownerType", ownerType.toString()), + buildParameters(ownerUrn, ownerType, ownerTypeUrn), auditStamp, semVerChange, description); } + + private static ImmutableMap buildParameters( + Urn ownerUrn, OwnershipType ownerType, Urn ownerTypeUrn) { + ImmutableMap.Builder builder = + new ImmutableMap.Builder() + .put("ownerUrn", ownerUrn.toString()) + .put("ownerType", ownerType.toString()); + if (ownerTypeUrn != null) { + builder.put("ownerTypeUrn", ownerTypeUrn.toString()); + } + + return builder.build(); + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/OwnershipChangeEventGenerator.java b/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/OwnershipChangeEventGenerator.java index b32958508cf240..1ef5d0f20da5a9 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/OwnershipChangeEventGenerator.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/timeline/eventgenerator/OwnershipChangeEventGenerator.java @@ -62,6 +62,7 @@ private static List computeDiffs( entityUrn)) .ownerUrn(targetOwner.getOwner()) .ownerType(targetOwner.getType()) + .ownerTypeUrn(targetOwner.getTypeUrn()) .auditStamp(auditStamp) .build()); } @@ -84,6 +85,7 @@ private static List computeDiffs( entityUrn)) .ownerUrn(baseOwner.getOwner()) .ownerType(baseOwner.getType()) + .ownerTypeUrn(baseOwner.getTypeUrn()) .auditStamp(auditStamp) .build()); ++baseOwnerIdx; @@ -104,6 +106,7 @@ private static List computeDiffs( entityUrn)) .ownerUrn(targetOwner.getOwner()) .ownerType(targetOwner.getType()) + .ownerTypeUrn(targetOwner.getTypeUrn()) .auditStamp(auditStamp) .build()); ++targetOwnerIdx; @@ -128,6 +131,7 @@ private static List computeDiffs( entityUrn)) .ownerUrn(baseOwner.getOwner()) .ownerType(baseOwner.getType()) + .ownerTypeUrn(baseOwner.getTypeUrn()) .auditStamp(auditStamp) .build()); ++baseOwnerIdx; @@ -150,6 +154,7 @@ private static List computeDiffs( entityUrn)) .ownerUrn(targetOwner.getOwner()) .ownerType(targetOwner.getType()) + .ownerTypeUrn(targetOwner.getTypeUrn()) .auditStamp(auditStamp) .build()); ++targetOwnerIdx; diff --git a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java index b06b7df1846bde..3897d663c96818 100644 --- a/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java +++ b/metadata-jobs/mae-consumer/src/test/java/com/linkedin/metadata/kafka/hook/event/EntityChangeEventGeneratorHookTest.java @@ -309,11 +309,16 @@ public void testInvokeEntityOwnerChange() throws Exception { final Ownership newOwners = new Ownership(); final Urn ownerUrn1 = Urn.createFromString("urn:li:corpuser:test1"); final Urn ownerUrn2 = Urn.createFromString("urn:li:corpuser:test2"); + final Urn ownerUrn3 = Urn.createFromString("urn:li:corpuser:test3"); newOwners.setOwners( new OwnerArray( ImmutableList.of( new Owner().setOwner(ownerUrn1).setType(OwnershipType.TECHNICAL_OWNER), - new Owner().setOwner(ownerUrn2).setType(OwnershipType.BUSINESS_OWNER)))); + new Owner().setOwner(ownerUrn2).setType(OwnershipType.BUSINESS_OWNER), + new Owner() + .setOwner(ownerUrn3) + .setType(OwnershipType.CUSTOM) + .setTypeUrn(Urn.createFromString("urn:li:ownershipType:my_custom_type"))))); final Ownership prevOwners = new Ownership(); prevOwners.setOwners(new OwnerArray()); event.setAspect(GenericRecordUtils.serializeAspect(newOwners)); @@ -354,7 +359,24 @@ public void testInvokeEntityOwnerChange() throws Exception { "ownerType", OwnershipType.BUSINESS_OWNER.toString()), actorUrn); - verifyProducePlatformEvent(_mockClient, platformEvent2, true); + verifyProducePlatformEvent(_mockClient, platformEvent2, false); + + PlatformEvent platformEvent3 = + createChangeEvent( + DATASET_ENTITY_NAME, + Urn.createFromString(TEST_DATASET_URN), + ChangeCategory.OWNER, + ChangeOperation.ADD, + ownerUrn3.toString(), + ImmutableMap.of( + "ownerUrn", + ownerUrn3.toString(), + "ownerType", + OwnershipType.CUSTOM.toString(), + "ownerTypeUrn", + "urn:li:ownershipType:my_custom_type"), + actorUrn); + verifyProducePlatformEvent(_mockClient, platformEvent3, true); } @Test diff --git a/smoke-test/tests/cypress/cypress/e2e/mutations/dataset_health.js b/smoke-test/tests/cypress/cypress/e2e/mutations/dataset_health.js index 072574e0f57aad..62ffd8a69d1c84 100644 --- a/smoke-test/tests/cypress/cypress/e2e/mutations/dataset_health.js +++ b/smoke-test/tests/cypress/cypress/e2e/mutations/dataset_health.js @@ -7,8 +7,8 @@ describe("dataset health test", () => { cy.login(); cy.goToDataset(urn, datasetName); // Ensure that the “Health” badge is present and there is an active incident warning - cy.get(`[href="/dataset/${urn}/Validation"]`).should("be.visible"); - cy.get(`[href="/dataset/${urn}/Validation"] span`).trigger("mouseover", { + cy.get(`[href="/dataset/${urn}/Quality"]`).should("be.visible"); + cy.get(`[href="/dataset/${urn}/Quality"] span`).trigger("mouseover", { force: true, }); cy.waitTextVisible("This asset may be unhealthy");