From 93687a8e280af974f44302c6d9de3f35156ca5e9 Mon Sep 17 00:00:00 2001 From: Jeff Puzzo Date: Fri, 3 May 2024 14:33:06 -0400 Subject: [PATCH] [RHOAIENG-2987] Artifacts - Details Page --- .../pages/pipelines/GlobalArtifactsRoutes.tsx | 12 ++ .../experiments/artifacts/ArtifactDetails.tsx | 89 +++++++++++ .../artifacts/ArtifactOverviewDetails.tsx | 105 +++++++++++++ .../experiments/artifacts/ArtifactUriLink.tsx | 71 +++++++++ .../experiments/artifacts/ArtifactsTable.tsx | 32 ++-- .../__tests__/ArtifactDetails.spec.tsx | 143 +++++++++++++++++ .../__tests__/ArtifactsTable.spec.tsx | 145 ++++++++++++++++++ .../global/experiments/artifacts/constants.ts | 5 + .../artifacts/useGetArtifactById.ts | 25 +++ .../global/experiments/artifacts/utils.ts | 55 +++++++ frontend/src/routes/pipelines/artifacts.ts | 3 + 11 files changed, 664 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails.tsx create mode 100644 frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactOverviewDetails.tsx 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/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.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactDetails.tsx new file mode 100644 index 0000000000..883d75039b --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/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 './useGetArtifactById'; +import { getArtifactName } from './utils'; +import { ArtifactDetailsTabKey } from './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/ArtifactOverviewDetails.tsx b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactOverviewDetails.tsx new file mode 100644 index 0000000000..ea79b53760 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactOverviewDetails.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +import { + Flex, + FlexItem, + Stack, + Title, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, +} from '@patternfly/react-core'; +import { CodeEditor } from '@patternfly/react-code-editor'; + +import { Artifact, Value } from '~/third_party/mlmd'; +import { ArtifactUriLink } from './ArtifactUriLink'; + +interface ArtifactOverviewDetailsProps { + artifact: Artifact.AsObject | undefined; +} + +export const ArtifactOverviewDetails: React.FC = ({ artifact }) => { + 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 ( + + + + Live system dataset + + + {artifact?.uri && ( + <> + URI + + + + + )} + + + + + + {!!artifact?.propertiesMap.length && ( + + + Properties + + + {artifact.propertiesMap.map(([propKey, propValue]) => ( + + {propKey} + + {getPropertyValue(propValue)} + + + ))} + + + + + )} + + {!!artifact?.customPropertiesMap.length && ( + + + Custom properties + + + {artifact.customPropertiesMap.map(([customPropKey, customPropValue]) => ( + + {customPropKey} + + {getPropertyValue(customPropValue)} + + + ))} + + + + + )} + + ); +}; 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..ee5d97fc04 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/ArtifactUriLink.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Flex, FlexItem, Truncate } from '@patternfly/react-core'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; + +import { usePipelinesAPI } from '~/concepts/pipelines/context'; +import usePipelinesAPIRoute from '~/concepts/pipelines/context/usePipelinesAPIRoute'; +import usePipelineNamespaceCR, { + dspaLoaded, +} from '~/concepts/pipelines/context/usePipelineNamespaceCR'; +import { PIPELINE_ROUTE_NAME_PREFIX } from '~/concepts/pipelines/const'; +import { generateGcsConsoleUri, generateMinioArtifactUrl, generateS3ArtifactUrl } from './utils'; + +interface ArtifactUriLinkProps { + uri: string; +} + +export const ArtifactUriLink: React.FC = ({ uri }) => { + const { namespace } = usePipelinesAPI(); + const crState = usePipelineNamespaceCR(namespace); + const isCrReady = dspaLoaded(crState); + const [pipelineApiRouteHost] = usePipelinesAPIRoute( + isCrReady, + crState[0]?.metadata.name ?? '', + namespace, + ); + let pipelineUiRouteHost = ''; + let uriLinkTo = ''; + + if (pipelineApiRouteHost) { + const [protocol, appHost] = pipelineApiRouteHost.split(PIPELINE_ROUTE_NAME_PREFIX); + pipelineUiRouteHost = `${protocol}${PIPELINE_ROUTE_NAME_PREFIX}ui-${appHost}`; + } + + if (uri.startsWith('gs:')) { + uriLinkTo = generateGcsConsoleUri(uri); + } + + if (uri.startsWith('s3:')) { + uriLinkTo = `${pipelineUiRouteHost}/${generateS3ArtifactUrl(uri)}`; + } + + if (uri.startsWith('http:') || uri.startsWith('https:')) { + uriLinkTo = uri; + } + + if (uri.startsWith('minio:')) { + uriLinkTo = `${pipelineUiRouteHost}/${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..058267a3a5 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,11 @@ 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 { FilterOptions, columns, initialFilterData, options } from './constants'; +import { getArtifactName } from './utils'; +import { ArtifactUriLink } from './ArtifactUriLink'; interface ArtifactsTableProps { artifacts: Artifact[] | null | undefined; @@ -32,6 +34,7 @@ export const ArtifactsTable: React.FC = ({ setPageToken: setRequestToken, setMaxResultSize, } = useMlmdListContext(nextPageToken); + const { namespace } = usePipelinesAPI(); const [page, setPage] = React.useState(1); const [filterData, setFilterData] = React.useState(initialFilterData); const onClearFilters = React.useCallback(() => setFilterData(initialFilterData), []); @@ -141,34 +144,21 @@ 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} - - - - - - - - - - - + ), - [], + [namespace], ); 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..8873489db8 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactDetails.spec.tsx @@ -0,0 +1,143 @@ +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 { 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'); + + 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(), + ]); + }); + + 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..35f52984c3 --- /dev/null +++ b/frontend/src/pages/pipelines/global/experiments/artifacts/__tests__/ArtifactsTable.spec.tsx @@ -0,0 +1,145 @@ +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 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'); + + 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(), + ]); + }); + + 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/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}`;