Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RHOAIENG-2987] Artifacts - Details Page #2765

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions frontend/src/components/MaxHeightCodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import { CodeEditor } from '@patternfly/react-code-editor';

export const MaxHeightCodeEditor: React.FC<
Partial<Omit<React.ComponentProps<typeof CodeEditor>, 'ref'>> & { maxHeight: number }
> = ({ maxHeight, ...props }) => {
const [contentHeight, setContentHeight] = React.useState<number>(maxHeight);

return (
<CodeEditor
onEditorDidMount={(editor) => setContentHeight(editor.getContentHeight())}
editorProps={{
height: `${contentHeight <= maxHeight ? contentHeight : maxHeight}px`,
}}
{...props}
/>
);
};
3 changes: 3 additions & 0 deletions frontend/src/components/NoValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import React from 'react';

export const NoValue: React.FC = () => <span className="pf-v5-u-color-200">No value</span>;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,11 +33,7 @@ export const renderDetailItems = (details: DetailItem[]): React.ReactNode => (
<DescriptionListGroup key={detail.key} data-testid={`detail-item-${detail.key}`}>
<DescriptionListTerm>{detail.key}</DescriptionListTerm>
<DescriptionListDescription data-testid="detail-item-value">
{!detail.value && detail.value !== 0 ? (
<span className="pf-v5-u-disabled-color-100">No value</span>
) : (
detail.value
)}
{!detail.value && detail.value !== 0 ? <NoValue /> : detail.value}
</DescriptionListDescription>
</DescriptionListGroup>
))}
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/concepts/pipelines/context/usePipelinesUiRoute.ts
Original file line number Diff line number Diff line change
@@ -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];
};
12 changes: 12 additions & 0 deletions frontend/src/pages/pipelines/GlobalArtifactsRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => (
<ProjectsRoutes>
Expand All @@ -13,6 +15,16 @@ const GlobalArtifactsRoutes: React.FC = () => (
element={<GlobalPipelineCoreLoader getInvalidRedirectPath={artifactsBaseRoute} />}
>
<Route index element={<GlobalArtifactsPage />} />
<Route
path=":artifactId"
element={
<GlobalPipelineCoreDetails
pageName="Artifacts"
redirectPath={artifactsBaseRoute}
BreadcrumbDetailsComponent={ArtifactDetails}
/>
}
/>
<Route path="*" element={<Navigate to="." />} />
</Route>
</ProjectsRoutes>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<EmptyState variant={EmptyStateVariant.lg}>
<EmptyStateHeader
titleText="Error loading artifact details"
icon={<EmptyStateIcon icon={ExclamationCircleIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>{artifactError.message}</EmptyStateBody>
</EmptyState>
);
}

if (!isArtifactLoaded) {
return (
<Bullseye>
<Spinner />
</Bullseye>
);
}

return (
<ApplicationsPage
title={artifactName ?? 'Error loading artifact'}
loaded={isArtifactLoaded}
loadError={artifactError}
breadcrumb={
<Breadcrumb>
{breadcrumbPath}
<BreadcrumbItem isActive style={{ maxWidth: 300 }}>
<Truncate content={artifactName ?? 'Loading...'} />
</BreadcrumbItem>
</Breadcrumb>
}
empty={false}
provideChildrenPadding
>
<Tabs aria-label="Artifact details tabs" activeKey={ArtifactDetailsTabKey.Overview}>
<Tab
eventKey={ArtifactDetailsTabKey.Overview}
title={<TabTitleText>Overview</TabTitleText>}
aria-label="Overview"
>
<ArtifactOverviewDetails artifact={artifact} />
</Tab>
<Tab
eventKey={ArtifactDetailsTabKey.LineageExplorer}
title={<TabTitleText>Lineage explorer</TabTitleText>}
isAriaDisabled
/>
</Tabs>
</ApplicationsPage>
);
};
Original file line number Diff line number Diff line change
@@ -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<ArtifactOverviewDetailsProps> = ({ artifact }) => {
const [pipelinesUiRoute, isPipelinesUiRouteLoaded] = usePipelinesUiRoute();

return (
<Flex
spaceItems={{ default: 'spaceItems2xl' }}
direction={{ default: 'column' }}
className="pf-v5-u-pt-lg pf-v5-u-pb-lg"
>
<FlexItem>
<Stack hasGutter>
<Title headingLevel="h3">Live system dataset</Title>
<DescriptionList isHorizontal data-testid="dataset-description-list">
<DescriptionListGroup>
{artifact?.uri && (
<>
<DescriptionListTerm>URI</DescriptionListTerm>
<DescriptionListDescription>
<ArtifactUriLink
uri={artifact.uri}
downloadHost={pipelinesUiRoute}
isLoaded={isPipelinesUiRouteLoaded}
/>
</DescriptionListDescription>
</>
)}
</DescriptionListGroup>
</DescriptionList>
</Stack>
</FlexItem>

{!!artifact?.propertiesMap.length && (
<FlexItem>
<Stack hasGutter>
<Title headingLevel="h3">Properties</Title>
<ArtifactPropertyDescriptionList
propertiesMap={artifact.propertiesMap}
testId="props-description-list"
/>
</Stack>
</FlexItem>
)}

{!!artifact?.customPropertiesMap.length && (
<FlexItem>
<Stack hasGutter>
<Title headingLevel="h3">Custom properties</Title>
<ArtifactPropertyDescriptionList
propertiesMap={artifact.customPropertiesMap}
testId="custom-props-description-list"
/>
</Stack>
</FlexItem>
)}
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -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<ArtifactPropertyDescriptionListProps> = ({
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 = (
<MaxHeightCodeEditor
isReadOnly
maxHeight={300}
code={JSON.stringify(property.structValue || property.protoValue, null, 2)}
/>
);
}

return propValue;
}, []);

return (
<DescriptionList isHorizontal data-testid={testId}>
<DescriptionListGroup>
{propertiesMap.map(([propKey, propValue]) => {
const value = getPropertyValue(propValue);

return (
<React.Fragment key={propKey}>
<DescriptionListTerm>{propKey}</DescriptionListTerm>
<DescriptionListDescription>
{!value && value !== 0 ? <NoValue /> : value}
</DescriptionListDescription>
</React.Fragment>
);
})}
</DescriptionListGroup>
</DescriptionList>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ArtifactDetails } from './ArtifactDetails';
Original file line number Diff line number Diff line change
@@ -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<ArtifactUriLinkProps> = ({
uri,
downloadHost,
isLoaded = true,
}) => {
let uriLinkTo = '';

if (!isLoaded) {
return <Skeleton />;
}

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 ? (
<Link to={uriLinkTo} target="_blank">
<Flex
alignItems={{ default: 'alignItemsCenter' }}
spaceItems={{ default: 'spaceItemsSm' }}
flexWrap={{ default: 'nowrap' }}
>
<FlexItem>
<Truncate content={uri} />
</FlexItem>

<FlexItem>
<ExternalLinkAltIcon />
</FlexItem>
</Flex>
</Link>
) : (
uri
);
};
Loading
Loading