Skip to content

Commit

Permalink
New asset catalog page (#20210)
Browse files Browse the repository at this point in the history
First stab at displaying the new asset catalog page.

Relies on the existing `assetsOrError` query to fetch asset definition
info, and adds logic to group assets by code location / owner / etc.
This will enable changes to this view, i.e. displaying a tooltip of the
asset keys per group, if desired.

Tagging others on this PR to get thoughts so far on the existing
implementation.

Next steps:
- Add tests for this component
- Unify styling for `UserDisplay` component across core and cloud.
Currently they display differently.
- Supporting asset search
- Displaying a background for the search section

Outstanding questions:
- How do we handle large numbers of assets on this page? Do we need to
implement some way to minimize each section, or paginate?

<img width="1457" alt="image"
src="https://github.com/dagster-io/dagster/assets/29110579/de586d6b-8ee9-4f62-9891-b491e937f562">
  • Loading branch information
clairelin135 authored Mar 7, 2024
1 parent c1f62a1 commit 258bc71
Show file tree
Hide file tree
Showing 8 changed files with 388 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import styled from 'styled-components';

import {showSharedToaster} from '../app/DomUtils';
import {useCopyToClipboard} from '../app/browser';
import {AnchorButton} from '../ui/AnchorButton';

type Props = {assetKey: {path: string[]}} & Partial<React.ComponentProps<typeof PageHeader>>;

Expand Down Expand Up @@ -116,6 +117,12 @@ export const AssetGlobalLineageLink = () => (
</Link>
);

export const AssetGlobalLineageButton = () => (
<AnchorButton intent="primary" icon={<Icon name="schema" />} to="/asset-groups">
View global asset lineage
</AnchorButton>
);

const BreadcrumbsWithSlashes = styled(Breadcrumbs)`
& li:not(:first-child)::after {
background: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export const ASSET_TABLE_DEFINITION_FRAGMENT = gql`
description
}
description
owners {
... on UserAssetOwner {
email
}
... on TeamAssetOwner {
team
}
}
repository {
id
name
Expand Down
331 changes: 331 additions & 0 deletions js_modules/dagster-ui/packages/ui-core/src/assets/AssetsOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
import {useQuery} from '@apollo/client';
import {Box, Colors, Heading, Icon, Page, Spinner, TextInput} from '@dagster-io/ui-components';
import qs from 'qs';
import {useContext} from 'react';
import {Link, useParams} from 'react-router-dom';
import styled from 'styled-components';

import {AssetGlobalLineageButton, AssetPageHeader} from './AssetPageHeader';
import {ASSET_CATALOG_TABLE_QUERY} from './AssetsCatalogTable';
import {AssetTableFragment} from './types/AssetTableFragment.types';
import {
AssetCatalogTableQuery,
AssetCatalogTableQueryVariables,
} from './types/AssetsCatalogTable.types';
import {useTrackPageView} from '../app/analytics';
import {TimeContext} from '../app/time/TimeContext';
import {browserTimezone} from '../app/time/browserTimezone';
import {TagIcon} from '../graph/OpTags';
import {useDocumentTitle} from '../hooks/useDocumentTitle';
import {useLaunchPadHooks} from '../launchpad/LaunchpadHooksContext';
import {ReloadAllButton} from '../workspace/ReloadAllButton';
import {buildRepoPathForHuman} from '../workspace/buildRepoAddress';
import {repoAddressAsHumanString, repoAddressAsURLString} from '../workspace/repoAddressAsString';
import {repoAddressFromPath} from '../workspace/repoAddressFromPath';
import {RepoAddress} from '../workspace/types';

type AssetCountsResult = {
countsByOwner: Record<string, number>;
countsByComputeKind: Record<string, number>;
countPerAssetGroup: CountPerGroupName[];
countPerCodeLocation: CountPerCodeLocation[];
};

type GroupMetadata = {
groupName: string;
repositoryLocationName: string;
repositoryName: string;
};

type CountPerGroupName = {
assetCount: number;
groupMetadata: GroupMetadata;
};

type CountPerCodeLocation = {
repoAddress: RepoAddress;
assetCount: number;
};

function buildAssetCountBySection(assets: AssetTableFragment[]): AssetCountsResult {
const assetCountByOwner: Record<string, number> = {};
const assetCountByComputeKind: Record<string, number> = {};
const assetCountByGroup: Record<string, number> = {};
const assetCountByCodeLocation: Record<string, number> = {};

assets
.filter((asset) => asset.definition)
.forEach((asset) => {
const assetDefinition = asset.definition!;
assetDefinition.owners.forEach((owner) => {
const ownerKey = owner.__typename === 'UserAssetOwner' ? owner.email : owner.team;
assetCountByOwner[ownerKey] = (assetCountByOwner[ownerKey] || 0) + 1;
});

const computeKind = assetDefinition.computeKind;
if (computeKind) {
assetCountByComputeKind[computeKind] = (assetCountByComputeKind[computeKind] || 0) + 1;
}

const groupName = assetDefinition.groupName;
const locationName = assetDefinition.repository.location.name;
const repositoryName = assetDefinition.repository.name;

if (groupName) {
const metadata: GroupMetadata = {
groupName,
repositoryLocationName: locationName,
repositoryName,
};
const groupIdentifier = JSON.stringify(metadata);
assetCountByGroup[groupIdentifier] = (assetCountByGroup[groupIdentifier] || 0) + 1;
}

const stringifiedCodeLocation = buildRepoPathForHuman(repositoryName, locationName);
assetCountByCodeLocation[stringifiedCodeLocation] =
(assetCountByCodeLocation[stringifiedCodeLocation] || 0) + 1;
});

const countPerAssetGroup = Object.entries(assetCountByGroup).map(([groupIdentifier, count]) => ({
assetCount: count,
groupMetadata: JSON.parse(groupIdentifier),
}));

return {
countsByOwner: assetCountByOwner,
countsByComputeKind: assetCountByComputeKind,
countPerAssetGroup,
countPerCodeLocation: Object.entries(assetCountByCodeLocation).map(([key, count]) => {
return {repoAddress: repoAddressFromPath(key)!, assetCount: count};
}),
};
}

interface AssetOverviewCategoryProps {
children: React.ReactNode;
assetsCount: number;
}

function getGreeting(timezone: string) {
const hour = Number(
new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
hourCycle: 'h23',
timeZone: timezone === 'Automatic' ? browserTimezone() : timezone,
}),
);
if (hour < 4) {
return 'Good evening';
} else if (hour < 12) {
return 'Good morning';
} else if (hour < 18) {
return 'Good afternoon';
} else {
return 'Good evening';
}
}

const CountForAssetType = ({children, assetsCount}: AssetOverviewCategoryProps) => {
return (
<Box
flex={{direction: 'row', justifyContent: 'space-between'}}
style={{width: 'calc(33% - 16px)'}}
>
<div>{children}</div>
<AssetCount>{assetsCount} assets</AssetCount>
</Box>
);
};

const SectionHeader = ({sectionName}: {sectionName: string}) => {
return (
<Box
flex={{alignItems: 'center', justifyContent: 'space-between'}}
padding={{horizontal: 24, vertical: 8}}
border="top-and-bottom"
>
<SectionName>{sectionName}</SectionName>
</Box>
);
};

const SectionBody = ({children}: {children: React.ReactNode}) => {
return (
<Box
padding={{horizontal: 24, vertical: 16}}
flex={{wrap: 'wrap'}}
style={{rowGap: '14px', columnGap: '24px'}}
>
{children}
</Box>
);
};

const linkToAssetGraphGroup = (groupMetadata: GroupMetadata) => {
return `/asset-groups?${qs.stringify({groups: JSON.stringify([groupMetadata])})}`;
};

const linkToAssetGraphComputeKind = (computeKind: string) => {
return `/asset-groups?${qs.stringify({
computeKindTags: JSON.stringify([computeKind]),
})}`;
};

const linkToCodeLocation = (repoAddress: RepoAddress) => {
return `/locations/${repoAddressAsURLString(repoAddress)}/assets`;
};

export const AssetsOverview = ({viewerName}: {viewerName?: string}) => {
useTrackPageView();

const params = useParams();
const currentPath: string[] = ((params as any)['0'] || '')
.split('/')
.filter((x: string) => x)
.map(decodeURIComponent);

useDocumentTitle('Assets');

const assetsQuery = useQuery<AssetCatalogTableQuery, AssetCatalogTableQueryVariables>(
ASSET_CATALOG_TABLE_QUERY,
{
notifyOnNetworkStatusChange: true,
},
);
const assetCountBySection = buildAssetCountBySection(
assetsQuery.data?.assetsOrError.__typename === 'AssetConnection'
? assetsQuery.data.assetsOrError.nodes
: [],
);
const {UserDisplay} = useLaunchPadHooks();
const {
timezone: [timezone],
} = useContext(TimeContext);

if (assetsQuery.loading) {
return (
<Page>
<AssetPageHeader assetKey={{path: currentPath}} />
<Box flex={{direction: 'row', justifyContent: 'center'}} style={{paddingTop: '100px'}}>
<Box flex={{direction: 'row', alignItems: 'center', gap: 16}}>
<Spinner purpose="body-text" />
<div style={{color: Colors.textLight()}}>Loading assets…</div>
</Box>
</Box>
</Page>
);
}

return (
<>
<AssetPageHeader
assetKey={{path: currentPath}}
right={<ReloadAllButton label="Reload definitions" />}
/>
<Box flex={{direction: 'column'}} style={{height: '100%', overflow: 'auto'}}>
<Box padding={64} flex={{justifyContent: 'center', alignItems: 'center'}}>
<Box style={{width: '60%'}} flex={{direction: 'column', gap: 16}}>
<Box flex={{direction: 'row', alignItems: 'center', justifyContent: 'space-between'}}>
<Heading>
{getGreeting(timezone)}
{viewerName ? `, ${viewerName}` : ''}
</Heading>
<Box flex={{direction: 'row', gap: 16, alignItems: 'center'}}>
<Link to="/assets">View all</Link>
<AssetGlobalLineageButton />
</Box>
</Box>
<TextInput />
</Box>
</Box>
<Box flex={{direction: 'column'}}>
{Object.keys(assetCountBySection.countsByOwner).length > 0 && (
<>
<SectionHeader sectionName="Owner" />
<SectionBody>
{Object.entries(assetCountBySection.countsByOwner).map(([label, count]) => (
<CountForAssetType key={label} assetsCount={count}>
<UserDisplay email={label} />
</CountForAssetType>
))}
</SectionBody>
</>
)}
{Object.keys(assetCountBySection.countsByComputeKind).length > 0 && (
<>
<SectionHeader sectionName="Compute kind" />
<SectionBody>
{Object.entries(assetCountBySection.countsByComputeKind).map(([label, count]) => (
<CountForAssetType key={label} assetsCount={count}>
<Box flex={{direction: 'row', gap: 4, alignItems: 'center'}}>
<TagIcon label={label} />
<Link to={linkToAssetGraphComputeKind(label)}>{label}</Link>
</Box>
</CountForAssetType>
))}
</SectionBody>
</>
)}
{assetCountBySection.countPerAssetGroup.length > 0 && (
<>
<SectionHeader sectionName="Asset groups" />
<SectionBody>
{assetCountBySection.countPerAssetGroup.map((assetGroupCount) => (
<CountForAssetType
key={JSON.stringify(assetGroupCount.groupMetadata)}
assetsCount={assetGroupCount.assetCount}
>
<Box flex={{direction: 'row', gap: 4, alignItems: 'center'}}>
<Icon name="asset_group" />
<Link to={linkToAssetGraphGroup(assetGroupCount.groupMetadata)}>
{assetGroupCount.groupMetadata.groupName}
</Link>
<span style={{color: Colors.textLighter()}}>
{assetGroupCount.groupMetadata.repositoryLocationName}
</span>
</Box>
</CountForAssetType>
))}
</SectionBody>
</>
)}
{assetCountBySection.countPerCodeLocation.length > 0 && (
<>
<SectionHeader sectionName="Code locations" />
<SectionBody>
{assetCountBySection.countPerCodeLocation.map((countPerCodeLocation) => (
<CountForAssetType
key={repoAddressAsHumanString(countPerCodeLocation.repoAddress)}
assetsCount={countPerCodeLocation.assetCount}
>
<Box flex={{direction: 'row', gap: 4, alignItems: 'center'}}>
<Icon name="folder" />
<Link to={linkToCodeLocation(countPerCodeLocation.repoAddress)}>
{repoAddressAsHumanString(countPerCodeLocation.repoAddress)}
</Link>
</Box>
</CountForAssetType>
))}
</SectionBody>
</>
)}
</Box>
</Box>
</>
);
};

// Imported via React.lazy, which requires a default export.
// eslint-disable-next-line import/no-default-export
export default AssetsOverview;

const SectionName = styled.span`
font-weight: 600;
color: ${Colors.textLight()};
font-size: 12px;
`;

const AssetCount = styled.span`
color: ${Colors.textLight()};
font-size: 14px;
`;

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

1 comment on commit 258bc71

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deploy preview for dagit-core-storybook ready!

✅ Preview
https://dagit-core-storybook-djeedgyeo-elementl.vercel.app

Built with commit 258bc71.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.