From 76bea1b0e421441fa7bd11b4613b3d7fbc35dfec Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Fri, 3 May 2024 17:45:40 -0400 Subject: [PATCH] [RHOAIENG-2987] Artifacts - Details Page --- .../src/components/MaxHeightCodeEditor.tsx | 18 +++ frontend/src/components/NoValue.tsx | 3 + .../pipelinesDetails/pipelineRun/utils.tsx | 7 +- .../pipelines/context/usePipelinesUiRoute.ts | 23 +++ .../pages/pipelines/GlobalArtifactsRoutes.tsx | 12 ++ .../ArtifactDetails/ArtifactDetails.tsx | 89 +++++++++++ .../ArtifactOverviewDetails.tsx | 79 ++++++++++ .../ArtifactPropertyDescriptionList.tsx | 58 +++++++ .../artifacts/ArtifactDetails/index.ts | 1 + .../experiments/artifacts/ArtifactUriLink.tsx | 61 +++++++ .../experiments/artifacts/ArtifactsTable.tsx | 38 ++--- .../__tests__/ArtifactDetails.spec.tsx | 147 +++++++++++++++++ .../__tests__/ArtifactsTable.spec.tsx | 149 ++++++++++++++++++ .../global/experiments/artifacts/constants.ts | 5 + .../artifacts/useGetArtifactById.ts | 25 +++ .../global/experiments/artifacts/utils.ts | 55 +++++++ .../ExecutionDetailsInputOutputSection.tsx | 9 +- .../ExecutionDetailsPropertiesValue.tsx | 4 +- frontend/src/routes/pipelines/artifacts.ts | 3 + 19 files changed, 756 insertions(+), 30 deletions(-) create mode 100644 frontend/src/components/MaxHeightCodeEditor.tsx create mode 100644 frontend/src/components/NoValue.tsx create mode 100644 frontend/src/concepts/pipelines/context/usePipelinesUiRoute.ts create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactDetails.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/index.ts create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactUriLink.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactDetails.spec.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactsTable.spec.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactById.ts create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/utils.ts diff --git a/frontend/src/components/MaxHeightCodeEditor.tsx b/frontend/src/components/MaxHeightCodeEditor.tsx new file mode 100644 index 0000000000..b246a77302 --- /dev/null +++ b/frontend/src/components/MaxHeightCodeEditor.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { CodeEditor } from '@patternfly/react-code-editor'; + +export const MaxHeightCodeEditor: React.FC< + Partial, 'ref'>> & { maxHeight: number } +> = ({ maxHeight, ...props }) => { + const [contentHeight, setContentHeight] = React.useState(maxHeight); + + return ( + setContentHeight(editor.getContentHeight())} + editorProps={{ + height: `${contentHeight <= maxHeight ? contentHeight : maxHeight}px`, + }} + {...props} + /> + ); +}; diff --git a/frontend/src/components/NoValue.tsx b/frontend/src/components/NoValue.tsx new file mode 100644 index 0000000000..d7a15cc360 --- /dev/null +++ b/frontend/src/components/NoValue.tsx @@ -0,0 +1,3 @@ +import React from 'react'; + +export const NoValue: React.FC = () => No value; diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx index 4a9304f1fb..3943c4870c 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/utils.tsx @@ -14,6 +14,7 @@ import { GlobeAmericasIcon } from '@patternfly/react-icons'; import { DateTimeKF, RuntimeConfigParamValue } from '~/concepts/pipelines/kfTypes'; import { PodKind } from '~/k8sTypes'; import { PodContainer } from '~/types'; +import { NoValue } from '~/components/NoValue'; export type DetailItem = { key: string; @@ -32,11 +33,7 @@ export const renderDetailItems = (details: DetailItem[]): React.ReactNode => ( {detail.key} - {!detail.value && detail.value !== 0 ? ( - No value - ) : ( - detail.value - )} + {!detail.value && detail.value !== 0 ? : detail.value} ))} diff --git a/frontend/src/concepts/pipelines/context/usePipelinesUiRoute.ts b/frontend/src/concepts/pipelines/context/usePipelinesUiRoute.ts new file mode 100644 index 0000000000..29349e5c9e --- /dev/null +++ b/frontend/src/concepts/pipelines/context/usePipelinesUiRoute.ts @@ -0,0 +1,23 @@ +import { PIPELINE_ROUTE_NAME_PREFIX } from '~/concepts/pipelines/const'; +import { usePipelinesAPI } from './PipelinesContext'; +import usePipelineNamespaceCR, { dspaLoaded } from './usePipelineNamespaceCR'; +import usePipelinesAPIRoute from './usePipelinesAPIRoute'; + +export const usePipelinesUiRoute = (): [string, boolean] => { + const { namespace } = usePipelinesAPI(); + const crState = usePipelineNamespaceCR(namespace); + const isCrReady = dspaLoaded(crState); + const [pipelinesApiRoute, isPipelinesApiRouteLoaded] = usePipelinesAPIRoute( + isCrReady, + crState[0]?.metadata.name ?? '', + namespace, + ); + let uiRoute = ''; + + if (pipelinesApiRoute) { + const [protocol, appHost] = pipelinesApiRoute.split(PIPELINE_ROUTE_NAME_PREFIX); + uiRoute = `${protocol}${PIPELINE_ROUTE_NAME_PREFIX}ui-${appHost}`; + } + + return [uiRoute, isPipelinesApiRouteLoaded]; +}; diff --git a/frontend/src/pages/pipelines/GlobalArtifactsRoutes.tsx b/frontend/src/pages/pipelines/GlobalArtifactsRoutes.tsx index 0280196852..cedbd549ad 100644 --- a/frontend/src/pages/pipelines/GlobalArtifactsRoutes.tsx +++ b/frontend/src/pages/pipelines/GlobalArtifactsRoutes.tsx @@ -5,6 +5,8 @@ import ProjectsRoutes from '~/concepts/projects/ProjectsRoutes'; import GlobalPipelineCoreLoader from '~/pages/pipelines/global/GlobalPipelineCoreLoader'; import { artifactsBaseRoute } from '~/routes'; import { GlobalArtifactsPage } from './global/experiments/artifacts'; +import GlobalPipelineCoreDetails from './global/GlobalPipelineCoreDetails'; +import { ArtifactDetails } from './global/experiments/artifacts/ArtifactDetails'; const GlobalArtifactsRoutes: React.FC = () => ( @@ -13,6 +15,16 @@ const GlobalArtifactsRoutes: React.FC = () => ( element={} > } /> + + } + /> } /> diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactDetails.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactDetails.tsx new file mode 100644 index 0000000000..11182e0976 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactDetails.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useParams } from 'react-router'; + +import { + Breadcrumb, + BreadcrumbItem, + Bullseye, + EmptyState, + EmptyStateBody, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + Spinner, + Tab, + TabTitleText, + Tabs, + Truncate, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; + +import { PipelineCoreDetailsPageComponent } from '~/concepts/pipelines/content/types'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { useGetArtifactById } from '~/pages/pipelines/global/experiments/artifacts/useGetArtifactById'; +import { getArtifactName } from '~/pages/pipelines/global/experiments/artifacts/utils'; +import { ArtifactDetailsTabKey } from '~/pages/pipelines/global/experiments/artifacts/constants'; +import { ArtifactOverviewDetails } from './ArtifactOverviewDetails'; + +export const ArtifactDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) => { + const { artifactId } = useParams(); + const [artifactResponse, isArtifactLoaded, artifactError] = useGetArtifactById( + Number(artifactId), + ); + const artifact = artifactResponse?.toObject(); + const artifactName = getArtifactName(artifact); + + if (artifactError) { + return ( + + } + headingLevel="h4" + /> + {artifactError.message} + + ); + } + + if (!isArtifactLoaded) { + return ( + + + + ); + } + + return ( + + {breadcrumbPath} + + + + + } + empty={false} + provideChildrenPadding + > + + Overview} + aria-label="Overview" + > + + + Lineage explorer} + isAriaDisabled + /> + + + ); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx new file mode 100644 index 0000000000..a1fd6c7f82 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactOverviewDetails.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { + Flex, + FlexItem, + Stack, + Title, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from '@patternfly/react-core'; + +import { Artifact } from '~/third_party/mlmd'; +import { usePipelinesUiRoute } from '~/concepts/pipelines/context/usePipelinesUiRoute'; +import { ArtifactUriLink } from '~/pages/pipelines/global/experiments/artifacts/ArtifactUriLink'; +import { ArtifactPropertyDescriptionList } from './ArtifactPropertyDescriptionList'; + +interface ArtifactOverviewDetailsProps { + artifact: Artifact.AsObject | undefined; +} + +export const ArtifactOverviewDetails: React.FC = ({ artifact }) => { + const [pipelinesUiRoute, isPipelinesUiRouteLoaded] = usePipelinesUiRoute(); + + return ( + + + + Live system dataset + + + {artifact?.uri && ( + <> + URI + + + + + )} + + + + + + {!!artifact?.propertiesMap.length && ( + + + Properties + + + + )} + + {!!artifact?.customPropertiesMap.length && ( + + + Custom properties + + + + )} + + ); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx new file mode 100644 index 0000000000..2412e5883f --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/ArtifactPropertyDescriptionList.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from '@patternfly/react-core'; + +import { Value } from '~/third_party/mlmd'; +import { NoValue } from '~/components/NoValue'; +import { MaxHeightCodeEditor } from '~/components/MaxHeightCodeEditor'; + +interface ArtifactPropertyDescriptionListProps { + testId?: string; + propertiesMap: [string, Value.AsObject][]; +} + +export const ArtifactPropertyDescriptionList: React.FC = ({ + propertiesMap, + testId, +}) => { + const getPropertyValue = React.useCallback((property: Value.AsObject): React.ReactNode => { + let propValue: React.ReactNode = + property.stringValue || property.intValue || property.doubleValue || property.boolValue || ''; + + if (property.structValue || property.protoValue) { + propValue = ( + + ); + } + + return propValue; + }, []); + + return ( + + + {propertiesMap.map(([propKey, propValue]) => { + const value = getPropertyValue(propValue); + + return ( + + {propKey} + + {!value && value !== 0 ? : value} + + + ); + })} + + + ); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/index.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/index.ts new file mode 100644 index 0000000000..131665e657 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails/index.ts @@ -0,0 +1 @@ +export { ArtifactDetails } from './ArtifactDetails'; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactUriLink.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactUriLink.tsx new file mode 100644 index 0000000000..6e8bcacc4d --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactUriLink.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Flex, FlexItem, Skeleton, Truncate } from '@patternfly/react-core'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; + +import { generateGcsConsoleUri, generateMinioArtifactUrl, generateS3ArtifactUrl } from './utils'; + +interface ArtifactUriLinkProps { + uri: string; + downloadHost: string; + isLoaded?: boolean; +} + +export const ArtifactUriLink: React.FC = ({ + uri, + downloadHost, + isLoaded = true, +}) => { + let uriLinkTo = ''; + + if (!isLoaded) { + return ; + } + + if (uri.startsWith('gs:')) { + uriLinkTo = generateGcsConsoleUri(uri); + } + + if (uri.startsWith('s3:')) { + uriLinkTo = `${downloadHost}/${generateS3ArtifactUrl(uri)}`; + } + + if (uri.startsWith('http:') || uri.startsWith('https:')) { + uriLinkTo = uri; + } + + if (uri.startsWith('minio:')) { + uriLinkTo = `${downloadHost}/${generateMinioArtifactUrl(uri)}`; + } + + return uriLinkTo ? ( + + + + + + + + + + + + ) : ( + uri + ); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx index 24e8e81613..87681a338b 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactsTable.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { Flex, FlexItem, TextInput, Truncate } from '@patternfly/react-core'; -import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { TextInput } from '@patternfly/react-core'; import { TableVariant, Td, Tr } from '@patternfly/react-table'; import { Artifact } from '~/third_party/mlmd'; @@ -12,8 +11,12 @@ import PipelinesTableRowTime from '~/concepts/pipelines/content/tables/Pipelines import { FilterToolbar } from '~/concepts/pipelines/content/tables/PipelineFilterBar'; import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; import { ArtifactType } from '~/concepts/pipelines/kfTypes'; -import { useMlmdListContext } from '~/concepts/pipelines/context'; +import { useMlmdListContext, usePipelinesAPI } from '~/concepts/pipelines/context'; +import { artifactsDetailsRoute } from '~/routes'; +import { usePipelinesUiRoute } from '~/concepts/pipelines/context/usePipelinesUiRoute'; import { FilterOptions, columns, initialFilterData, options } from './constants'; +import { getArtifactName } from './utils'; +import { ArtifactUriLink } from './ArtifactUriLink'; interface ArtifactsTableProps { artifacts: Artifact[] | null | undefined; @@ -32,6 +35,8 @@ export const ArtifactsTable: React.FC = ({ setPageToken: setRequestToken, setMaxResultSize, } = useMlmdListContext(nextPageToken); + const { namespace } = usePipelinesAPI(); + const [pipelinesUiRoute, isPipelinesUiRouteLoaded] = usePipelinesUiRoute(); const [page, setPage] = React.useState(1); const [filterData, setFilterData] = React.useState(initialFilterData); const onClearFilters = React.useCallback(() => setFilterData(initialFilterData), []); @@ -141,34 +146,25 @@ export const ArtifactsTable: React.FC = ({ (artifact: Artifact.AsObject) => ( - {artifact.name || - artifact.customPropertiesMap.find(([name]) => name === 'display_name')?.[1].stringValue} + + {getArtifactName(artifact)} + {artifact.id} {artifact.type} - - - - - - - - - - - + ), - [], + [isPipelinesUiRouteLoaded, namespace, pipelinesUiRoute], ); return ( diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactDetails.spec.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactDetails.spec.tsx new file mode 100644 index 0000000000..cf353f448e --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactDetails.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; + +import { render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { Artifact } from '~/third_party/mlmd'; +import { artifactsBaseRoute } from '~/routes'; +import * as useGetArtifactById from '~/pages/pipelines/global/experiments/artifacts/useGetArtifactById'; +import * as usePipelinesUiRoute from '~/concepts/pipelines/context/usePipelinesUiRoute'; +import { ArtifactDetails } from '~/pages/pipelines/global/experiments/artifacts/ArtifactDetails'; +import GlobalPipelineCoreDetails from '~/pages/pipelines/global/GlobalPipelineCoreDetails'; + +jest.mock('~/redux/selectors', () => ({ + ...jest.requireActual('~/redux/selectors'), + useUser: jest.fn(() => ({ isAdmin: true })), +})); + +jest.mock('~/concepts/pipelines/context/PipelinesContext', () => ({ + usePipelinesAPI: jest.fn(() => ({ + pipelinesServer: { + initializing: false, + installed: true, + compatible: true, + timedOut: false, + name: 'dspa', + }, + namespace: 'Test namespace', + project: { + metadata: { + name: 'Test namespace', + }, + kind: 'Project', + }, + apiAvailable: true, + })), +})); + +describe('ArtifactDetails', () => { + const useGetArtifactByIdSpy = jest.spyOn(useGetArtifactById, 'useGetArtifactById'); + const usePipelinesUiRouteSpy = jest.spyOn(usePipelinesUiRoute, 'usePipelinesUiRoute'); + + beforeEach(() => { + useGetArtifactByIdSpy.mockReturnValue([ + { + toObject: jest.fn(() => ({ + id: 1, + typeId: 14, + type: 'system.Artifact', + uri: 'https://test-artifact!-aiplatform.googleapis.com/v1/12.15', + propertiesMap: [], + customPropertiesMap: [ + [ + 'display_name', + { + stringValue: 'vertex_model', + }, + ], + [ + 'resourceName', + { + stringValue: '12.15', + }, + ], + ], + state: 2, + createTimeSinceEpoch: 1711113121829, + lastUpdateTimeSinceEpoch: 1711113121829, + })), + } as unknown as Artifact, + true, + undefined, + jest.fn(), + ]); + + usePipelinesUiRouteSpy.mockReturnValue(['dspa-pipeline-ui-route', true]); + }); + + it('renders page breadcrumbs', () => { + render( + + + , + ); + + const breadcrumb = screen.getByRole('navigation', { name: 'Breadcrumb' }); + + expect( + within(breadcrumb).getByRole('link', { name: 'Artifacts - Test namespace' }), + ).toBeVisible(); + expect(within(breadcrumb).getByText('vertex_model')).toBeVisible(); + }); + + it('renders artifact name as page header with the Overview tab initially selected', () => { + render( + + + , + ); + + expect(screen.getByTestId('app-page-title')).toHaveTextContent('vertex_model'); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + expect(overviewTab).toBeVisible(); + expect(overviewTab).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders Overview tab metadata contents', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Live system dataset' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Custom properties' })).toBeVisible(); + + const datasetDescriptionList = screen.getByTestId('dataset-description-list'); + expect(within(datasetDescriptionList).getByRole('term')).toHaveTextContent('URI'); + expect(within(datasetDescriptionList).getByRole('definition')).toHaveTextContent( + 'https://test-artifact!-aiplatform.googleapis.com/v1/12.15', + ); + + const customPropsDescriptionList = screen.getByTestId('custom-props-description-list'); + const customPropsDescriptionListTerms = within(customPropsDescriptionList).getAllByRole('term'); + const customPropsDescriptionListValues = within(customPropsDescriptionList).getAllByRole( + 'definition', + ); + + expect(customPropsDescriptionListTerms[0]).toHaveTextContent('display_name'); + expect(customPropsDescriptionListValues[0]).toHaveTextContent('vertex_model'); + expect(customPropsDescriptionListTerms[1]).toHaveTextContent('resourceName'); + expect(customPropsDescriptionListValues[1]).toHaveTextContent('12.15'); + }); +}); diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactsTable.spec.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactsTable.spec.tsx new file mode 100644 index 0000000000..f20b717a70 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactsTable.spec.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { BrowserRouter } from 'react-router-dom'; + +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { Artifact } from '~/third_party/mlmd'; +import * as useGetArtifactsList from '~/pages/pipelines/global/experiments/artifacts/useGetArtifactsList'; +import * as MlmdListContext from '~/concepts/pipelines/context/MlmdListContext'; +import * as usePipelinesUiRoute from '~/concepts/pipelines/context/usePipelinesUiRoute'; +import EnsureAPIAvailability from '~/concepts/pipelines/EnsureAPIAvailability'; +import EnsureCompatiblePipelineServer from '~/concepts/pipelines/EnsureCompatiblePipelineServer'; +import { ArtifactsList } from '~/pages/pipelines/global/experiments/artifacts/ArtifactsList'; + +jest.mock('~/redux/selectors', () => ({ + ...jest.requireActual('~/redux/selectors'), + useUser: jest.fn(() => ({ isAdmin: true })), +})); + +jest.mock('~/concepts/pipelines/context/PipelinesContext', () => ({ + usePipelinesAPI: jest.fn(() => ({ + pipelinesServer: { + initializing: false, + installed: true, + compatible: true, + timedOut: false, + name: 'dspa', + }, + namespace: 'Test namespace', + project: { + metadata: { + name: 'Test namespace', + }, + kind: 'Project', + }, + apiAvailable: true, + })), +})); + +describe('ArtifactsTable', () => { + const useGetArtifactsListSpy = jest.spyOn(useGetArtifactsList, 'useGetArtifactsList'); + const useMlmdListContextSpy = jest.spyOn(MlmdListContext, 'useMlmdListContext'); + const usePipelinesUiRouteSpy = jest.spyOn(usePipelinesUiRoute, 'usePipelinesUiRoute'); + + beforeEach(() => { + useMlmdListContextSpy.mockReturnValue({ + filterQuery: '', + pageToken: '', + maxResultSize: 10, + orderBy: undefined, + setFilterQuery: jest.fn(), + setPageToken: jest.fn(), + setMaxResultSize: jest.fn(), + setOrderBy: jest.fn(), + }); + + useGetArtifactsListSpy.mockReturnValue([ + { + artifacts: [ + { + toObject: jest.fn(() => ({ + id: 1, + typeId: 14, + type: 'system.Artifact', + uri: 'https://test-artifact!-aiplatform.googleapis.com/v1/12.15', + propertiesMap: [], + customPropertiesMap: [ + [ + 'display_name', + { + stringValue: 'vertex_model', + }, + ], + [ + 'resourceName', + { + stringValue: '12.15', + }, + ], + ], + state: 2, + createTimeSinceEpoch: 1711113121829, + })), + }, + { + toObject: jest.fn(() => ({ + id: 2, + typeId: 15, + type: 'system.Dataset', + uri: 'https://test2-artifact!-aiplatform.googleapis.com/v1/12.10', + customPropertiesMap: [ + [ + 'display_name', + { + stringValue: 'iris_dataset', + }, + ], + [ + 'resourceName', + { + stringValue: '12.10', + }, + ], + ], + state: 2, + createTimeSinceEpoch: 1611399342384, + })), + }, + ] as unknown as Artifact[], + nextPageToken: '', + }, + true, + undefined, + jest.fn(), + ]); + + usePipelinesUiRouteSpy.mockReturnValue(['dspa-pipeline-ui-route', true]); + }); + + it('renders artifacts table with data', () => { + render( + + + + + + + + + , + ); + + const firstRow = screen.getByRole('row', { name: /vertex_model/ }); + expect(firstRow).toHaveTextContent('vertex_model'); + expect(firstRow).toHaveTextContent('1'); + expect(firstRow).toHaveTextContent('system.Artifact'); + expect(firstRow).toHaveTextContent('https://test-artifact!-aiplatform.googleapis.com/v1/12.15'); + expect(firstRow).toHaveTextContent('1 month ago'); + + const secondRow = screen.getByRole('row', { name: /iris_dataset/ }); + expect(secondRow).toHaveTextContent('iris_dataset'); + expect(secondRow).toHaveTextContent('2'); + expect(secondRow).toHaveTextContent('system.Dataset'); + expect(secondRow).toHaveTextContent( + 'https://test2-artifact!-aiplatform.googleapis.com/v1/12.10', + ); + expect(secondRow).toHaveTextContent('23 Jan 2021'); + }); +}); diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts index 16e737bbf0..5231502e9b 100644 --- a/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/constants.ts @@ -49,3 +49,8 @@ export const columns: SortableData[] = [ width: 15, }, ]; + +export enum ArtifactDetailsTabKey { + Overview = 'overview', + LineageExplorer = 'lineage-explorer', +} diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactById.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactById.ts new file mode 100644 index 0000000000..ba6c66f0b3 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/useGetArtifactById.ts @@ -0,0 +1,25 @@ +import React from 'react'; + +import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import { Artifact, GetArtifactsByIDRequest } from '~/third_party/mlmd'; +import useFetchState, { FetchState } from '~/utilities/useFetchState'; + +export const useGetArtifactById = ( + artifactId: number, + refreshRate?: number, +): FetchState => { + const { metadataStoreServiceClient } = usePipelinesAPI(); + + const fetchArtifact = React.useCallback(async () => { + const request = new GetArtifactsByIDRequest(); + request.setArtifactIdsList([artifactId]); + + const response = await metadataStoreServiceClient.getArtifactsByID(request); + + return response.getArtifactsList()[0]; + }, [artifactId, metadataStoreServiceClient]); + + return useFetchState(fetchArtifact, undefined, { + refreshRate, + }); +}; diff --git a/frontend/src/pages/pipelines/global/experiments/artifacts/utils.ts b/frontend/src/pages/pipelines/global/experiments/artifacts/utils.ts new file mode 100644 index 0000000000..16e30aea92 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/utils.ts @@ -0,0 +1,55 @@ +/** URI related utils source: https://github.com/kubeflow/pipelines/blob/master/frontend/src/lib/Utils.tsx */ +import { Artifact } from '~/third_party/mlmd'; + +export const getArtifactName = (artifact: Artifact.AsObject | undefined): string | undefined => + artifact?.name || + artifact?.customPropertiesMap.find(([name]) => name === 'display_name')?.[1].stringValue; + +export function buildQuery(queriesMap?: { [key: string]: string | number | undefined }): string { + const queryContent = Object.entries(queriesMap || {}) + .filter((entry): entry is [string, string | number] => entry[1] != null) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + if (!queryContent) { + return ''; + } + return `?${queryContent}`; +} + +/** + * Generates a cloud console uri from gs:// uri + * + * @param gcsUri Gcs uri that starts with gs://, like gs://bucket/path/file + * @returns A link user can open to visit cloud console page. + */ +export function generateGcsConsoleUri(uri: string): string { + const gcsPrefix = 'gs://'; + return `https://console.cloud.google.com/storage/browser/${uri.substring(gcsPrefix.length)}`; +} + +/** + * Generates an HTTPS API URL from minio:// uri + * + * @param uri Minio uri that starts with minio://, like minio://ml-pipeline/path/file + * @returns A URL that leads to the artifact data. Returns undefined when minioUri is not valid. + */ +export function generateMinioArtifactUrl(uri: string, peek?: number): string | undefined { + const matches = uri.match(/^minio:\/\/([^/]+)\/(.+)$/); + + return matches + ? `artifacts/minio/${matches[1]}/${matches[2]}${buildQuery({ + peek, + })}` + : undefined; +} + +/** + * Generates an HTTPS API URL from s3:// uri + * + * @param uri S3 uri that starts with s3://, like s3://ml-pipeline/path/file + * @returns A URL that leads to the artifact data. Returns undefined when s3Uri is not valid. + */ +export function generateS3ArtifactUrl(uri: string): string | undefined { + const matches = uri.match(/^s3:\/\/([^/]+)\/(.+)$/); + return matches ? `artifacts/s3/${matches[1]}/${matches[2]}${buildQuery()}` : undefined; +} diff --git a/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsInputOutputSection.tsx b/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsInputOutputSection.tsx index 91b351b939..d533f925b1 100644 --- a/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsInputOutputSection.tsx +++ b/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsInputOutputSection.tsx @@ -1,9 +1,12 @@ import React from 'react'; +import { Link } from 'react-router-dom'; import { Bullseye, Spinner, Stack, StackItem, Title } from '@patternfly/react-core'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { Event } from '~/third_party/mlmd'; import { useGetLinkedArtifactsByEvents } from '~/concepts/pipelines/apiHooks/mlmd/useGetLinkedArtifactsByEvents'; import { getArtifactNameFromEvent } from '~/pages/pipelines/global/experiments/executions/utils'; +import { artifactsDetailsRoute } from '~/routes'; +import { usePipelinesAPI } from '~/concepts/pipelines/context'; type ExecutionDetailsInputOutputSectionProps = { isLoaded: boolean; @@ -18,6 +21,7 @@ const ExecutionDetailsInputOutputSection: React.FC { + const { namespace } = usePipelinesAPI(); const [linkedArtifacts, isLinkedArtifactsLoaded] = useGetLinkedArtifactsByEvents(events); if (!isLoaded || !isLinkedArtifactsLoaded) { @@ -72,8 +76,9 @@ const ExecutionDetailsInputOutputSection: React.FC {id} - {/* TODO: add artifact details page line */} - {data.name} + + {data.name} + {type} {data.uri} diff --git a/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsPropertiesValue.tsx b/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsPropertiesValue.tsx index 9e2baa54a2..3669b61a9c 100644 --- a/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsPropertiesValue.tsx +++ b/frontend/src/pages/pipelines/global/experiments/executions/details/ExecutionDetailsPropertiesValue.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { CodeEditor } from '@patternfly/react-code-editor'; import { MlmdMetadataValueType } from '~/pages/pipelines/global/experiments/executions/utils'; +import { MaxHeightCodeEditor } from '~/components/MaxHeightCodeEditor'; type ExecutionDetailsPropertiesValueProps = { value: MlmdMetadataValueType; }; const ExecutionDetailsPropertiesValueCode = ({ code }: { code: string }) => ( - + ); const ExecutionDetailsPropertiesValue: React.FC = ({ diff --git a/frontend/src/routes/pipelines/artifacts.ts b/frontend/src/routes/pipelines/artifacts.ts index 7fc3437a73..2cf33ee76e 100644 --- a/frontend/src/routes/pipelines/artifacts.ts +++ b/frontend/src/routes/pipelines/artifacts.ts @@ -3,3 +3,6 @@ export const globArtifactsAll = `${artifactsRootPath}/*`; export const artifactsBaseRoute = (namespace: string | undefined): string => !namespace ? artifactsRootPath : `${artifactsRootPath}/${namespace}`; + +export const artifactsDetailsRoute = (namespace: string, artifactId: number): string => + `${artifactsBaseRoute(namespace)}/${artifactId}`;