Skip to content

Commit

Permalink
[ui] Asset view support for column lineage metadata (#20525)
Browse files Browse the repository at this point in the history
## Summary & Motivation

This PR adds a new "Column" selector to the Asset > Lineage page for
assets that emit `lineage` materialization event metadata. Choosing a
column switches you to a column-oriented version of the graph, which
supports the same upstream/downstream scoping + navigation as the normal
lineage view, but shows column-level arrows and info.

This PR makes a few changes to our asset layout system in order to
support this new DAG styling:

- The asset layout engine takes a wider range of it's hardcoded
constants as configuration (previously just `direction`), and you can
override Dagre layout settings. This allows us to run asset layout (with
the worker, indexdb caching, etc), but with smaller `column` nodes. The
options were already part of the cache keys, so this all works pretty
smoothly!

- I found an open PR on Dagre that fixes the issue with asset groups
overlapping occasionally because the layout engine could not account for
the presence of the "title bar" we render on them! I added the
adjustments to our existing `dagre.patch` file. This is important in
this PR because the nodes are the columns and the groups are the assets,
so they overlap badly without this fix.
 
## How I Tested These Changes

<img width="1728" alt="image"
src="https://github.com/dagster-io/dagster/assets/1037212/0f5aebfd-cb5b-436e-b344-23b6a8f85870">
<img width="1563" alt="image"
src="https://github.com/dagster-io/dagster/assets/1037212/4eccf376-4835-42b1-ad2c-590cb3917b1a">
<img width="1205" alt="image"
src="https://github.com/dagster-io/dagster/assets/1037212/16f659a9-cb90-489b-b0ce-03a09a4e1a9c">

---------

Co-authored-by: bengotow <[email protected]>
  • Loading branch information
2 people authored and benpankow committed Mar 21, 2024
1 parent 2397d8f commit 39c24a3
Show file tree
Hide file tree
Showing 27 changed files with 1,056 additions and 179 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import chevron_right from '../icon-svgs/chevron_right.svg';
import close from '../icon-svgs/close.svg';
import code_location from '../icon-svgs/code_location.svg';
import collapse_arrows from '../icon-svgs/collapse_arrows.svg';
import column_lineage from '../icon-svgs/column_lineage.svg';
import concept_book from '../icon-svgs/concept-book.svg';
import console_icon from '../icon-svgs/console.svg';
import content_copy from '../icon-svgs/content_copy.svg';
Expand Down Expand Up @@ -284,6 +285,7 @@ export const Icons = {
console: console_icon,
content_copy,
collapse_arrows,
column_lineage,
corporate_fare,
delete: deleteSVG,
done,
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion js_modules/dagster-ui/packages/ui-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"codemirror": "^5.65.2",
"color": "^3.0.0",
"cronstrue": "^1.84.0",
"dagre": "^0.8.5",
"dagre": "dagster-io/dagre#0.8.5",
"date-fns": "^2.28.0",
"dayjs": "^1.11.7",
"deepmerge": "^4.2.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {Box, Caption, Colors, Icon, StyledTag, Tooltip} from '@dagster-io/ui-components';
import {Link} from 'react-router-dom';
import styled from 'styled-components';

import {
AssetInsetForHoverEffect,
AssetNameRow,
AssetNodeBox,
AssetNodeContainer,
} from './AssetNode';
import {AssetNodeFragment} from './types/AssetNode.types';
import {Timestamp} from '../app/time/Timestamp';
import {assetDetailsPathForKey} from '../assets/assetDetailsPathForKey';
import {AssetColumnLineageLocalColumn} from '../assets/lineage/useColumnLineageDataForAssets';
import {AssetComputeKindTag} from '../graph/OpTags';
import {AssetKeyInput} from '../graphql/types';
import {iconForColumnType} from '../metadata/TableSchema';
import {Description} from '../pipelines/Description';

export const AssetColumnsGroupNode = ({
selected,
definition,
height,
asOf,
}: {
selected: boolean;
definition: AssetNodeFragment;
asOf: string | undefined;
height: number;
}) => {
return (
<AssetInsetForHoverEffect style={{marginLeft: 12, marginRight: 12}}>
<AssetNodeContainer $selected={selected}>
<div style={{minHeight: 24}} />
<AssetNodeBox $selected={selected} $isSource={definition.isSource} $noScale>
<AssetNameRow definition={definition} />
<Box style={{height: height - 60}} flex={{direction: 'column'}}></Box>
<Box border="top" padding={{horizontal: 8, vertical: 2}} style={{minHeight: 22}}>
{asOf ? (
<Caption color={Colors.textLighter()}>
<Timestamp timestamp={{ms: Number(asOf)}} />
</Caption>
) : undefined}
</Box>
</AssetNodeBox>
<AssetComputeKindTag definition={definition} style={{right: 10, paddingTop: 7}} />
</AssetNodeContainer>
</AssetInsetForHoverEffect>
);
};

export const AssetColumnNode = ({
assetKey,
column,
blueBackground,
}: {
assetKey: AssetKeyInput;
column: AssetColumnLineageLocalColumn;
blueBackground: boolean;
}) => {
const icon = iconForColumnType(column.type ?? '');

return (
<Box flex={{direction: 'column'}}>
<Tooltip
key={column.name}
position="bottom-left"
content={
<div style={{maxWidth: 500}}>
<Description
maxHeight={400}
description={column.description || 'No description provided'}
/>
</div>
}
>
<ColumnLink
to={assetDetailsPathForKey(assetKey, {view: 'lineage', column: column.name})}
$blueBackground={blueBackground}
>
{icon ? <Icon name={icon} /> : <span style={{width: 16}} />}
<Caption style={{whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'}}>
{column.name}
</Caption>
</ColumnLink>
</Tooltip>
</Box>
);
};

const ColumnLink = styled(Link)<{$blueBackground: boolean}>`
height: 28px;
margin: 2px 12px;
padding-left: 2px;
padding-right: 4px;
display: flex;
gap: 4px;
align-items: center;
transition: background 100ms linear;
border-radius: 8px;
${StyledTag} {
background: none;
color: ${Colors.textLight()};
}
${(p) =>
p.$blueBackground
? `
background: ${Colors.backgroundBlue()}`
: `
&:hover {
text-decoration: none;
background: ${Colors.backgroundLightHover()};
}`}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import pickBy from 'lodash/pickBy';
import uniq from 'lodash/uniq';
import without from 'lodash/without';
import * as React from 'react';
import {useMemo} from 'react';
import styled from 'styled-components';

import {AssetEdges} from './AssetEdges';
Expand Down Expand Up @@ -269,7 +270,11 @@ const AssetGraphExplorerWithData = ({
});
const focusGroupIdAfterLayoutRef = React.useRef('');

const {layout, loading, async} = useAssetLayout(assetGraphData, expandedGroups, direction);
const {layout, loading, async} = useAssetLayout(
assetGraphData,
expandedGroups,
useMemo(() => ({direction}), [direction]),
);

React.useEffect(() => {
if (!loading) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ interface Props {
}

export const AssetNode = React.memo(({definition, selected}: Props) => {
const displayName = definition.assetKey.path[definition.assetKey.path.length - 1]!;
const isSource = definition.isSource;

const {liveData} = useAssetLiveData(definition.assetKey);
return (
<AssetInsetForHoverEffect>
<Box flex={{direction: 'row', justifyContent: 'space-between', alignItems: 'center'}}>
<Box
flex={{direction: 'row', justifyContent: 'space-between', alignItems: 'center'}}
style={{minHeight: 24}}
>
<StaleReasonsTag liveData={liveData} assetKey={definition.assetKey} include="upstream" />
<ChangedReasonsTag
changedReasons={definition.changedReasons}
Expand All @@ -42,21 +44,7 @@ export const AssetNode = React.memo(({definition, selected}: Props) => {
</Box>
<AssetNodeContainer $selected={selected}>
<AssetNodeBox $selected={selected} $isSource={isSource}>
<AssetName $isSource={isSource}>
<span style={{marginTop: 1}}>
<Icon name={isSource ? 'source_asset' : 'asset'} />
</span>
<div
data-tooltip={displayName}
data-tooltip-style={isSource ? NameTooltipStyleSource : NameTooltipStyle}
style={{overflow: 'hidden', textOverflow: 'ellipsis'}}
>
{withMiddleTruncation(displayName, {
maxLength: ASSET_NODE_NAME_MAX_LENGTH,
})}
</div>
<div style={{flex: 1}} />
</AssetName>
<AssetNameRow definition={definition} />
<Box style={{padding: '6px 8px'}} flex={{direction: 'column', gap: 4}} border="top">
{definition.description ? (
<AssetDescription $color={Colors.textDefault()}>
Expand All @@ -81,6 +69,28 @@ export const AssetNode = React.memo(({definition, selected}: Props) => {
);
}, isEqual);

export const AssetNameRow = ({definition}: {definition: AssetNodeFragment}) => {
const displayName = definition.assetKey.path[definition.assetKey.path.length - 1]!;

return (
<AssetName $isSource={definition.isSource}>
<span style={{marginTop: 1}}>
<Icon name={definition.isSource ? 'source_asset' : 'asset'} />
</span>
<div
data-tooltip={displayName}
data-tooltip-style={definition.isSource ? NameTooltipStyleSource : NameTooltipStyle}
style={{overflow: 'hidden', textOverflow: 'ellipsis'}}
>
{withMiddleTruncation(displayName, {
maxLength: ASSET_NODE_NAME_MAX_LENGTH,
})}
</div>
<div style={{flex: 1}} />
</AssetName>
);
};

const AssetNodeRowBox = styled(Box)`
white-space: nowrap;
line-height: 12px;
Expand Down Expand Up @@ -244,7 +254,7 @@ export const ASSET_NODE_FRAGMENT = gql`
}
`;

const AssetInsetForHoverEffect = styled.div`
export const AssetInsetForHoverEffect = styled.div`
padding: 10px 4px 2px 4px;
height: 100%;
Expand All @@ -253,7 +263,7 @@ const AssetInsetForHoverEffect = styled.div`
}
`;

const AssetNodeContainer = styled.div<{$selected: boolean}>`
export const AssetNodeContainer = styled.div<{$selected: boolean}>`
user-select: none;
cursor: pointer;
padding: 6px;
Expand All @@ -264,7 +274,11 @@ const AssetNodeShowOnHover = styled.span`
display: none;
`;

const AssetNodeBox = styled.div<{$isSource: boolean; $selected: boolean}>`
export const AssetNodeBox = styled.div<{
$isSource: boolean;
$selected: boolean;
$noScale?: boolean;
}>`
${(p) =>
p.$isSource
? `border: 2px dashed ${p.$selected ? Colors.accentGrayHover() : Colors.accentGray()}`
Expand All @@ -280,7 +294,7 @@ const AssetNodeBox = styled.div<{$isSource: boolean; $selected: boolean}>`
&:hover {
${(p) => !p.$selected && `border: 2px solid ${Colors.lineageNodeBorderHover()};`};
box-shadow: ${Colors.shadowDefault()} 0px 1px 4px 0px;
scale: 1.03;
scale: ${(p) => (p.$noScale ? '1' : '1.03')};
${AssetNodeShowOnHover} {
display: initial;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import {DagsterTypeSummary} from '../dagstertype/DagsterType';
import {DagsterTypeFragment} from '../dagstertype/types/DagsterType.types';
import {METADATA_ENTRY_FRAGMENT} from '../metadata/MetadataEntry';
import {TableSchemaLineageContext} from '../metadata/TableSchema';
import {Description} from '../pipelines/Description';
import {SidebarSection, SidebarTitle} from '../pipelines/SidebarComponents';
import {ResourceContainer, ResourceHeader} from '../pipelines/SidebarOpHelpers';
Expand Down Expand Up @@ -159,7 +160,12 @@ export const SidebarAssetInfo = ({graphNode}: {graphNode: GraphNode}) => {

{assetMetadata.length > 0 && (
<SidebarSection title="Metadata">
<AssetMetadataTable assetMetadata={assetMetadata} repoLocation={repoAddress?.location} />
<TableSchemaLineageContext.Provider value={{assetKey: asset.assetKey}}>
<AssetMetadataTable
assetMetadata={assetMetadata}
repoLocation={repoAddress?.location}
/>
</TableSchemaLineageContext.Provider>
</SidebarSection>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function isHiddenAssetGroupJob(jobName: string) {
//
export type GraphId = string;
export const toGraphId = (key: {path: string[]}): GraphId => JSON.stringify(key.path);
export const fromGraphID = (graphId: GraphId): AssetNodeKeyFragment => ({
export const fromGraphId = (graphId: GraphId): AssetNodeKeyFragment => ({
path: JSON.parse(graphId),
__typename: 'AssetKey',
});
Expand Down Expand Up @@ -266,7 +266,7 @@ export const itemWithAssetKey = (key: {path: string[]}) => {
return (asset: {assetKey: {path: string[]}}) => tokenForAssetKey(asset.assetKey) === token;
};

export const isGroupId = (str: string) => /^[^@:]+@[^@:]+:[^@:]+$/.test(str);
export const isGroupId = (str: string) => /^[^@:]+@[^@:]+:.+$/.test(str);

export const groupIdForNode = (node: GraphNode) =>
[
Expand All @@ -281,7 +281,7 @@ export const groupIdForNode = (node: GraphNode) =>
export const getUpstreamNodes = memoize(
(assetKey: AssetNodeKeyFragment, graphData: GraphData): AssetNodeKeyFragment[] => {
const upstream = Object.keys(graphData.upstream[toGraphId(assetKey)] || {});
const currentUpstream = upstream.map((graphId) => fromGraphID(graphId));
const currentUpstream = upstream.map((graphId) => fromGraphId(graphId));
return [
assetKey,
...currentUpstream,
Expand Down
Loading

0 comments on commit 39c24a3

Please sign in to comment.