From 75dc7aedba419c23200c86b8a65a5aaa19382219 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Sat, 16 Nov 2024 20:27:17 -0800 Subject: [PATCH] [ui] Split AssetNodeOverview into sub-components (#25956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AssetNodeOverview component was approaching 1000 LOC and assembles a lot of metadata, etc. at the top that is far from where it’s rendered. This PR doesn’t contain any logic changes, I just broke the file up so that the metadata being pulled out of the props is closer to where it’s used and the whole thing is a bit easier to grok. Co-authored-by: bengotow --- .../ui-core/src/assets/AssetNodeOverview.tsx | 925 ------------------ .../packages/ui-core/src/assets/AssetView.tsx | 4 +- .../src/assets/overview/AssetNodeOverview.tsx | 388 ++++++++ .../overview/AutomationDetailsSection.tsx | 106 ++ .../ui-core/src/assets/overview/Common.tsx | 59 ++ .../assets/overview/ComputeDetailsSection.tsx | 104 ++ .../src/assets/overview/DefinitionSection.tsx | 246 +++++ .../src/assets/overview/LineageSection.tsx | 99 ++ 8 files changed, 1004 insertions(+), 927 deletions(-) delete mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/AssetNodeOverview.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/AssetNodeOverview.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/AutomationDetailsSection.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/Common.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/ComputeDetailsSection.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/DefinitionSection.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/assets/overview/LineageSection.tsx diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetNodeOverview.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetNodeOverview.tsx deleted file mode 100644 index f3f25d26fcbd8..0000000000000 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetNodeOverview.tsx +++ /dev/null @@ -1,925 +0,0 @@ -// eslint-disable-next-line no-restricted-imports -import { - Body, - Body2, - Box, - Button, - ButtonLink, - Caption, - Colors, - ConfigTypeSchema, - Icon, - MiddleTruncate, - NonIdealState, - Skeleton, - Subtitle2, - Tag, - Tooltip, -} from '@dagster-io/ui-components'; -import dayjs from 'dayjs'; -import React, {useMemo, useState} from 'react'; -import {Link} from 'react-router-dom'; -import {UserDisplay} from 'shared/runs/UserDisplay.oss'; -import styled from 'styled-components'; - -import {AssetDefinedInMultipleReposNotice} from './AssetDefinedInMultipleReposNotice'; -import {AssetEventMetadataEntriesTable} from './AssetEventMetadataEntriesTable'; -import {metadataForAssetNode} from './AssetMetadata'; -import {insitigatorsByType} from './AssetNodeInstigatorTag'; -import {EvaluationUserLabel} from './AutoMaterializePolicyPage/EvaluationConditionalLabel'; -import {DependsOnSelfBanner} from './DependsOnSelfBanner'; -import {LargeCollapsibleSection} from './LargeCollapsibleSection'; -import {MaterializationTag} from './MaterializationTag'; -import {OverdueTag, freshnessPolicyDescription} from './OverdueTag'; -import {RecentUpdatesTimeline} from './RecentUpdatesTimeline'; -import {SimpleStakeholderAssetStatus} from './SimpleStakeholderAssetStatus'; -import {UnderlyingOpsOrGraph} from './UnderlyingOpsOrGraph'; -import {AssetChecksStatusSummary} from './asset-checks/AssetChecksStatusSummary'; -import {assetDetailsPathForKey} from './assetDetailsPathForKey'; -import {buildConsolidatedColumnSchema} from './buildConsolidatedColumnSchema'; -import {globalAssetGraphPathForAssetsAndDescendants} from './globalAssetGraphPathToString'; -import {AssetKey} from './types'; -import {AssetNodeDefinitionFragment} from './types/AssetNodeDefinition.types'; -import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types'; -import {useLatestPartitionEvents} from './useLatestPartitionEvents'; -import {useRecentAssetEvents} from './useRecentAssetEvents'; -import {showCustomAlert} from '../app/CustomAlertProvider'; -import {showSharedToaster} from '../app/DomUtils'; -import {COMMON_COLLATOR} from '../app/Util'; -import {useCopyToClipboard} from '../app/browser'; -import {useAssetsLiveData} from '../asset-data/AssetLiveDataProvider'; -import { - LiveDataForNode, - displayNameForAssetKey, - isHiddenAssetGroupJob, - sortAssetKeys, - tokenForAssetKey, -} from '../asset-graph/Utils'; -import {StatusDot} from '../asset-graph/sidebar/StatusDot'; -import {AssetNodeForGraphQueryFragment} from '../asset-graph/types/useAssetGraphData.types'; -import {CodeLink, getCodeReferenceKey} from '../code-links/CodeLink'; -import {DagsterTypeSummary} from '../dagstertype/DagsterType'; -import {AssetKind, isCanonicalStorageKindTag, isSystemTag} from '../graph/KindTags'; -import {IntMetadataEntry} from '../graphql/types'; -import {useStateWithStorage} from '../hooks/useStateWithStorage'; -import {isCanonicalRowCountMetadataEntry} from '../metadata/MetadataEntry'; -import { - TableSchema, - TableSchemaAssetContext, - isCanonicalCodeSourceEntry, - isCanonicalTableNameEntry, - isCanonicalUriEntry, -} from '../metadata/TableSchema'; -import {RepositoryLink} from '../nav/RepositoryLink'; -import {ScheduleOrSensorTag} from '../nav/ScheduleOrSensorTag'; -import {useRepositoryLocationForAddress} from '../nav/useRepositoryLocationForAddress'; -import {Description} from '../pipelines/Description'; -import {PipelineTag} from '../pipelines/PipelineReference'; -import {numberFormatter} from '../ui/formatters'; -import {buildTagString} from '../ui/tagAsString'; -import {buildRepoAddress} from '../workspace/buildRepoAddress'; -import {workspacePathFromAddress} from '../workspace/workspacePath'; - -const SystemTagsToggle = ({tags}: {tags: Array<{key: string; value: string}>}) => { - const [shown, setShown] = useStateWithStorage('show-asset-definition-system-tags', Boolean); - - if (!shown) { - return ( - - setShown(true)}> - - Show system tags ({tags.length || 0}) - - - - - ); - } else { - return ( - - - {tags.map((tag, idx) => ( - {buildTagString(tag)} - ))} - - - setShown(false)}> - - Hide system tags - - - - - - ); - } -}; - -export const AssetNodeOverview = ({ - assetKey, - assetNode, - cachedAssetNode, - upstream, - downstream, - liveData, - dependsOnSelf, -}: { - assetKey: AssetKey; - assetNode: AssetNodeDefinitionFragment | undefined | null; - cachedAssetNode: AssetTableDefinitionFragment | undefined | null; - upstream: AssetNodeForGraphQueryFragment[] | null; - downstream: AssetNodeForGraphQueryFragment[] | null; - liveData: LiveDataForNode | undefined; - dependsOnSelf: boolean; -}) => { - const cachedOrLiveAssetNode = assetNode ?? cachedAssetNode; - const repoAddress = cachedOrLiveAssetNode - ? buildRepoAddress( - cachedOrLiveAssetNode.repository.name, - cachedOrLiveAssetNode.repository.location.name, - ) - : null; - const location = useRepositoryLocationForAddress(repoAddress); - - const {assetType, assetMetadata} = metadataForAssetNode(assetNode); - const {schedules, sensors} = useMemo(() => insitigatorsByType(assetNode), [assetNode]); - const configType = assetNode?.configField?.configType; - const assetConfigSchema = configType && configType.key !== 'Any' ? configType : null; - const visibleJobNames = - cachedOrLiveAssetNode?.jobNames.filter((jobName) => !isHiddenAssetGroupJob(jobName)) || []; - - const assetNodeLoadTimestamp = location ? location.updatedTimestamp * 1000 : undefined; - - const {materialization, observation, loading} = useLatestPartitionEvents( - assetKey, - assetNodeLoadTimestamp, - liveData, - ); - - const { - materializations, - observations, - loading: materializationsLoading, - } = useRecentAssetEvents( - cachedOrLiveAssetNode?.partitionDefinition ? undefined : cachedOrLiveAssetNode?.assetKey, - {}, - {assetHasDefinedPartitions: false}, - ); - - // Start loading neighboring assets data immediately to avoid waterfall. - useAssetsLiveData( - useMemo( - () => [ - ...(downstream || []).map((node) => node.assetKey), - ...(upstream || []).map((node) => node.assetKey), - ], - [downstream, upstream], - ), - ); - - if (loading || !cachedOrLiveAssetNode) { - return ; - } - - const {tableSchema, tableSchemaLoadTimestamp} = buildConsolidatedColumnSchema({ - materialization, - definition: assetNode, - definitionLoadTimestamp: assetNodeLoadTimestamp, - }); - - const rowCountMeta: IntMetadataEntry | undefined = materialization?.metadataEntries.find( - (entry) => isCanonicalRowCountMetadataEntry(entry), - ) as IntMetadataEntry | undefined; - - const renderStatusSection = () => ( - - - - - Latest {assetNode?.isObservable ? 'observation' : 'materialization'} - - - {liveData ? ( - - ) : ( - - )} - {assetNode && assetNode.freshnessPolicy && ( - - )} - - - {liveData?.assetChecks.length ? ( - - Check results - - - ) : undefined} - {rowCountMeta?.intValue ? ( - - Row count - - {numberFormatter.format(rowCountMeta.intValue)} - - - ) : undefined} - - {cachedOrLiveAssetNode.isPartitioned ? null : ( - - )} - - ); - - const renderDescriptionSection = () => - cachedOrLiveAssetNode.description ? ( - - ) : ( - - ); - - const renderLineageSection = () => ( - <> - {dependsOnSelf && ( - - - - )} - - - - Upstream assets - {upstream?.length ? ( - - ) : ( - - - - )} - - - Downstream assets - {downstream?.length ? ( - - ) : ( - - - - )} - - - - ); - - const storageKindTag = cachedOrLiveAssetNode.tags?.find(isCanonicalStorageKindTag); - const filteredTags = cachedOrLiveAssetNode.tags?.filter( - (tag) => tag.key !== 'dagster/storage_kind', - ); - - const nonSystemTags = filteredTags?.filter((tag) => !isSystemTag(tag)); - const systemTags = filteredTags?.filter(isSystemTag); - - const tableNameMetadata = assetNode?.metadataEntries?.find(isCanonicalTableNameEntry); - const uriMetadata = assetNode?.metadataEntries?.find(isCanonicalUriEntry); - const codeSource = assetNode?.metadataEntries?.find(isCanonicalCodeSourceEntry); - - const renderDefinitionSection = () => ( - - - - - {cachedOrLiveAssetNode.groupName} - - - - - - - - - - {location && ( - - Loaded {dayjs.unix(location.updatedTimestamp).fromNow()} - - )} - - - - - {cachedOrLiveAssetNode.owners && - cachedOrLiveAssetNode.owners.length > 0 && - cachedOrLiveAssetNode.owners.map((owner, idx) => - owner.__typename === 'UserAssetOwner' ? ( - - - - ) : ( - - {owner.team} - - ), - )} - - - {cachedOrLiveAssetNode.computeKind && ( - - )} - - - {(cachedOrLiveAssetNode.kinds.length > 1 || !cachedOrLiveAssetNode.computeKind) && - cachedOrLiveAssetNode.kinds.map((kind) => ( - - ))} - - - {(tableNameMetadata || uriMetadata || storageKindTag) && ( - - {tableNameMetadata && ( - - - - - )} - {uriMetadata && ( - - {uriMetadata.__typename === 'TextMetadataEntry' ? ( - uriMetadata.text - ) : ( - - {uriMetadata.url} - - )} - - - )} - {storageKindTag && ( - - )} - - )} - - - {filteredTags && filteredTags.length > 0 && ( - - - {nonSystemTags.map((tag, idx) => ( - {buildTagString(tag)} - ))} - - {systemTags.length > 0 && } - - )} - - - {codeSource && - codeSource.codeReferences && - codeSource.codeReferences.map((ref) => ( - - ))} - - - ); - - const renderAutomationDetailsSection = () => { - const attributes = [ - { - label: 'Jobs', - children: visibleJobNames.map((jobName) => ( - - )), - }, - { - label: 'Sensors', - children: assetNode ? ( - sensors.length > 0 ? ( - - ) : null - ) : ( - - ), - }, - { - label: 'Schedules', - children: assetNode ? ( - schedules.length > 0 && ( - - ) - ) : ( - - ), - }, - { - label: 'Freshness policy', - children: assetNode ? ( - assetNode?.freshnessPolicy && ( - {freshnessPolicyDescription(assetNode.freshnessPolicy)} - ) - ) : ( - - ), - }, - ]; - - if ( - attributes.every((props) => isEmptyChildren(props.children)) && - !cachedOrLiveAssetNode.automationCondition - ) { - return ( - - ); - } else { - if (assetNode?.automationCondition && assetNode?.automationCondition.label) { - return ( - - ); - } - } - - return ( - - {attributes.map((props) => ( - - ))} - - ); - }; - - const renderComputeDetailsSection = () => { - if (!assetNode) { - return ; - } - return ( - - - - - - - - {assetNode.opVersion} - - - {[...assetNode.requiredResources] - .sort((a, b) => COMMON_COLLATOR.compare(a.resourceKey, b.resourceKey)) - .map((resource) => ( - - - - {repoAddress ? ( - - {resource.resourceKey} - - ) : ( - resource.resourceKey - )} - - - ))} - - - - {assetConfigSchema && ( - { - showCustomAlert({ - title: 'Config schema', - body: ( - - ), - }); - }} - > - View config details - - )} - - - - {assetType && assetType.displayName !== 'Any' && ( - { - showCustomAlert({ - title: 'Type summary', - body: , - }); - }} - > - View type details - - )} - - - - {assetNode.backfillPolicy?.description} - - - ); - }; - - return ( - - - {renderStatusSection()} - - - {renderDescriptionSection()} - - {tableSchema && ( - - - - - - )} - - - } - /> - - e.stopPropagation()} - > - View in graph - - } - > - {renderLineageSection()} - - - } - right={ - <> - - {renderDefinitionSection()} - - - {renderAutomationDetailsSection()} - - {cachedOrLiveAssetNode.isExecutable ? ( - - {renderComputeDetailsSection()} - - ) : null} - - } - /> - ); -}; - -const AssetNodeOverviewContainer = ({ - left, - right, -}: { - left: React.ReactNode; - right: React.ReactNode; -}) => ( - - - {left} - - - {right} - - -); - -const isEmptyChildren = (children: React.ReactNode) => - !children || (children instanceof Array && children.length === 0); - -const CopyButton = ({value}: {value: string}) => { - const copy = useCopyToClipboard(); - const onCopy = async () => { - copy(value); - await showSharedToaster({ - intent: 'success', - icon: 'copy_to_clipboard_done', - message: 'Copied!', - }); - }; - - return ( - -
- -
-
- ); -}; - -const AttributeAndValue = ({ - label, - children, -}: { - label: React.ReactNode; - children: React.ReactNode; -}) => { - if (isEmptyChildren(children)) { - return null; - } - - return ( - - {label} - - {children} - - - ); -}; - -const NoValue = () => ; - -export const AssetNodeOverviewNonSDA = ({ - assetKey, - lastMaterialization, -}: { - assetKey: AssetKey; - lastMaterialization: {timestamp: string; runId: string} | null | undefined; -}) => { - const {materializations, observations, loading} = useRecentAssetEvents( - assetKey, - {}, - {assetHasDefinedPartitions: false}, - ); - - return ( - - -
- {lastMaterialization ? ( - - ) : ( - Never materialized - )} -
- -
- - } - right={ - - - - - - } - /> - ); -}; - -export const AssetNodeOverviewLoading = () => ( - - - - - - - - - - - - - - - - } - right={ - - - }> - - - }> - - - }> - - - - - } - /> -); - -const SectionEmptyState = ({ - title, - description, - learnMoreLink, -}: { - title: string; - description: string; - learnMoreLink: string; -}) => ( - - {title} - {description} - {learnMoreLink ? ( - - Learn more - - ) : undefined} - -); - -const AssetLinksWithStatus = ({ - assets, - displayedByDefault = 20, -}: { - assets: AssetNodeForGraphQueryFragment[]; - displayedByDefault?: number; -}) => { - const [displayedCount, setDisplayedCount] = useState(displayedByDefault); - - const displayed = React.useMemo( - () => assets.sort((a, b) => sortAssetKeys(a.assetKey, b.assetKey)).slice(0, displayedCount), - [assets, displayedCount], - ); - - return ( - - {displayed.map((asset) => ( - -
- - -
- - ))} - - {displayed.length < assets.length ? ( - - ) : displayed.length > displayedByDefault ? ( - - ) : undefined} - -
- ); -}; - -const UserAssetOwnerWrapper = styled.div` - > div { - background-color: ${Colors.backgroundGray()}; - } -`; - -const SectionSkeleton = () => ( - - - - - -); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx index 161e809cac044..4e0e1888700b7 100644 --- a/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/AssetView.tsx @@ -11,7 +11,6 @@ import {AssetFeatureContext} from './AssetFeatureContext'; import {ASSET_NODE_DEFINITION_FRAGMENT, AssetNodeDefinition} from './AssetNodeDefinition'; import {ASSET_NODE_INSTIGATORS_FRAGMENT} from './AssetNodeInstigatorTag'; import {AssetNodeLineage} from './AssetNodeLineage'; -import {AssetNodeOverview, AssetNodeOverviewNonSDA} from './AssetNodeOverview'; import {AssetPartitions} from './AssetPartitions'; import {AssetPlotsPage} from './AssetPlotsPage'; import {AssetTabs} from './AssetTabs'; @@ -22,8 +21,8 @@ import {LaunchAssetExecutionButton} from './LaunchAssetExecutionButton'; import {UNDERLYING_OPS_ASSET_NODE_FRAGMENT} from './UnderlyingOpsOrGraph'; import {AssetChecks} from './asset-checks/AssetChecks'; import {assetDetailsPathForKey} from './assetDetailsPathForKey'; +import {AssetNodeOverview, AssetNodeOverviewNonSDA} from './overview/AssetNodeOverview'; import {AssetKey, AssetViewParams} from './types'; -import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types'; import { AssetViewDefinitionNodeFragment, AssetViewDefinitionQuery, @@ -34,6 +33,7 @@ import {healthRefreshHintFromLiveData} from './usePartitionHealthData'; import {useReportEventsModal} from './useReportEventsModal'; import {useWipeModal} from './useWipeModal'; import {gql, useQuery} from '../apollo-client'; +import {AssetTableDefinitionFragment} from './types/AssetTableFragment.types'; import {currentPageAtom} from '../app/analytics'; import {Timestamp} from '../app/time/Timestamp'; import {AssetLiveDataRefreshButton, useAssetLiveData} from '../asset-data/AssetLiveDataProvider'; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AssetNodeOverview.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AssetNodeOverview.tsx new file mode 100644 index 0000000000000..8c84edda92807 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AssetNodeOverview.tsx @@ -0,0 +1,388 @@ +import { + Box, + Caption, + Colors, + NonIdealState, + Skeleton, + Subtitle2, + Tag, +} from '@dagster-io/ui-components'; +import React, {useMemo} from 'react'; +import {Link} from 'react-router-dom'; + +import {AssetEventMetadataEntriesTable} from '../AssetEventMetadataEntriesTable'; +import {metadataForAssetNode} from '../AssetMetadata'; +import {AutomationDetailsSection} from './AutomationDetailsSection'; +import {AttributeAndValue, NoValue, SectionEmptyState} from './Common'; +import {ComputeDetailsSection} from './ComputeDetailsSection'; +import {DefinitionSection} from './DefinitionSection'; +import {LineageSection} from './LineageSection'; +import {useAssetsLiveData} from '../../asset-data/AssetLiveDataProvider'; +import {LiveDataForNode} from '../../asset-graph/Utils'; +import {AssetNodeForGraphQueryFragment} from '../../asset-graph/types/useAssetGraphData.types'; +import {IntMetadataEntry} from '../../graphql/types'; +import {isCanonicalRowCountMetadataEntry} from '../../metadata/MetadataEntry'; +import {TableSchema, TableSchemaAssetContext} from '../../metadata/TableSchema'; +import {useRepositoryLocationForAddress} from '../../nav/useRepositoryLocationForAddress'; +import {Description} from '../../pipelines/Description'; +import {numberFormatter} from '../../ui/formatters'; +import {buildRepoAddress} from '../../workspace/buildRepoAddress'; +import {LargeCollapsibleSection} from '../LargeCollapsibleSection'; +import {MaterializationTag} from '../MaterializationTag'; +import {OverdueTag} from '../OverdueTag'; +import {RecentUpdatesTimeline} from '../RecentUpdatesTimeline'; +import {SimpleStakeholderAssetStatus} from '../SimpleStakeholderAssetStatus'; +import {AssetChecksStatusSummary} from '../asset-checks/AssetChecksStatusSummary'; +import {buildConsolidatedColumnSchema} from '../buildConsolidatedColumnSchema'; +import {globalAssetGraphPathForAssetsAndDescendants} from '../globalAssetGraphPathToString'; +import {AssetKey} from '../types'; +import {AssetNodeDefinitionFragment} from '../types/AssetNodeDefinition.types'; +import {AssetTableDefinitionFragment} from '../types/AssetTableFragment.types'; +import {useLatestPartitionEvents} from '../useLatestPartitionEvents'; +import {useRecentAssetEvents} from '../useRecentAssetEvents'; + +export const AssetNodeOverview = ({ + assetKey, + assetNode, + cachedAssetNode, + upstream, + downstream, + liveData, + dependsOnSelf, +}: { + assetKey: AssetKey; + assetNode: AssetNodeDefinitionFragment | undefined | null; + cachedAssetNode: AssetTableDefinitionFragment | undefined | null; + upstream: AssetNodeForGraphQueryFragment[] | null; + downstream: AssetNodeForGraphQueryFragment[] | null; + liveData: LiveDataForNode | undefined; + dependsOnSelf: boolean; +}) => { + const cachedOrLiveAssetNode = assetNode ?? cachedAssetNode; + const repoAddress = cachedOrLiveAssetNode + ? buildRepoAddress( + cachedOrLiveAssetNode.repository.name, + cachedOrLiveAssetNode.repository.location.name, + ) + : null; + const location = useRepositoryLocationForAddress(repoAddress); + + const {assetMetadata} = metadataForAssetNode(assetNode); + + const assetNodeLoadTimestamp = location ? location.updatedTimestamp * 1000 : undefined; + + const {materialization, observation, loading} = useLatestPartitionEvents( + assetKey, + assetNodeLoadTimestamp, + liveData, + ); + + const { + materializations, + observations, + loading: materializationsLoading, + } = useRecentAssetEvents( + cachedOrLiveAssetNode?.partitionDefinition ? undefined : cachedOrLiveAssetNode?.assetKey, + {}, + {assetHasDefinedPartitions: false}, + ); + + // Start loading neighboring assets data immediately to avoid waterfall. + useAssetsLiveData( + useMemo( + () => [ + ...(downstream || []).map((node) => node.assetKey), + ...(upstream || []).map((node) => node.assetKey), + ], + [downstream, upstream], + ), + ); + + if (loading || !cachedOrLiveAssetNode) { + return ; + } + + const {tableSchema, tableSchemaLoadTimestamp} = buildConsolidatedColumnSchema({ + materialization, + definition: assetNode, + definitionLoadTimestamp: assetNodeLoadTimestamp, + }); + + const rowCountMeta: IntMetadataEntry | undefined = materialization?.metadataEntries.find( + (entry) => isCanonicalRowCountMetadataEntry(entry), + ) as IntMetadataEntry | undefined; + + const renderStatusSection = () => ( + + + + + Latest {assetNode?.isObservable ? 'observation' : 'materialization'} + + + {liveData ? ( + + ) : ( + + )} + {assetNode && assetNode.freshnessPolicy && ( + + )} + + + {liveData?.assetChecks.length ? ( + + Check results + + + ) : undefined} + {rowCountMeta?.intValue ? ( + + Row count + + {numberFormatter.format(rowCountMeta.intValue)} + + + ) : undefined} + + {cachedOrLiveAssetNode.isPartitioned ? null : ( + + )} + + ); + + return ( + + + {renderStatusSection()} + + + {cachedOrLiveAssetNode.description ? ( + + ) : ( + + )} + + {tableSchema && ( + + + + + + )} + + + } + /> + + e.stopPropagation()} + > + View in graph + + } + > + + + + } + right={ + <> + + + + + + + {cachedOrLiveAssetNode.isExecutable ? ( + + + + ) : null} + + } + /> + ); +}; + +const AssetNodeOverviewContainer = ({ + left, + right, +}: { + left: React.ReactNode; + right: React.ReactNode; +}) => ( + + + {left} + + + {right} + + +); + +export const AssetNodeOverviewNonSDA = ({ + assetKey, + lastMaterialization, +}: { + assetKey: AssetKey; + lastMaterialization: {timestamp: string; runId: string} | null | undefined; +}) => { + const {materializations, observations, loading} = useRecentAssetEvents( + assetKey, + {}, + {assetHasDefinedPartitions: false}, + ); + + return ( + + +
+ {lastMaterialization ? ( + + ) : ( + Never materialized + )} +
+ +
+ + } + right={ + + + + + + } + /> + ); +}; + +export const AssetNodeOverviewLoading = () => ( + + + + + + + + + + + + + + + + } + right={ + + + }> + + + }> + + + }> + + + + + } + /> +); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AutomationDetailsSection.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AutomationDetailsSection.tsx new file mode 100644 index 0000000000000..60f730808072b --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/AutomationDetailsSection.tsx @@ -0,0 +1,106 @@ +import {Body, Box} from '@dagster-io/ui-components'; +import React, {useMemo} from 'react'; + +import {insitigatorsByType} from '../AssetNodeInstigatorTag'; +import {AttributeAndValue, SectionEmptyState, SectionSkeleton, isEmptyChildren} from './Common'; +import {isHiddenAssetGroupJob} from '../../asset-graph/Utils'; +import {ScheduleOrSensorTag} from '../../nav/ScheduleOrSensorTag'; +import {PipelineTag} from '../../pipelines/PipelineReference'; +import {RepoAddress} from '../../workspace/types'; +import {EvaluationUserLabel} from '../AutoMaterializePolicyPage/EvaluationConditionalLabel'; +import {freshnessPolicyDescription} from '../OverdueTag'; +import {AssetNodeDefinitionFragment} from '../types/AssetNodeDefinition.types'; +import {AssetTableDefinitionFragment} from '../types/AssetTableFragment.types'; + +export const AutomationDetailsSection = ({ + repoAddress, + assetNode, + cachedOrLiveAssetNode, +}: { + repoAddress: RepoAddress | null; + assetNode: AssetNodeDefinitionFragment | null | undefined; + cachedOrLiveAssetNode: AssetNodeDefinitionFragment | AssetTableDefinitionFragment; +}) => { + const {schedules, sensors} = useMemo(() => insitigatorsByType(assetNode), [assetNode]); + const visibleJobNames = + cachedOrLiveAssetNode?.jobNames.filter((jobName) => !isHiddenAssetGroupJob(jobName)) || []; + + const attributes = [ + { + label: 'Jobs', + children: visibleJobNames.map((jobName) => ( + + )), + }, + { + label: 'Sensors', + children: assetNode ? ( + sensors.length > 0 ? ( + + ) : null + ) : ( + + ), + }, + { + label: 'Schedules', + children: assetNode ? ( + schedules.length > 0 && ( + + ) + ) : ( + + ), + }, + { + label: 'Freshness policy', + children: assetNode ? ( + assetNode?.freshnessPolicy && ( + {freshnessPolicyDescription(assetNode.freshnessPolicy)} + ) + ) : ( + + ), + }, + ]; + + if ( + attributes.every((props) => isEmptyChildren(props.children)) && + !cachedOrLiveAssetNode.automationCondition + ) { + return ( + + ); + } else { + if (assetNode?.automationCondition && assetNode?.automationCondition.label) { + return ( + + ); + } + } + + return ( + + {attributes.map((props) => ( + + ))} + + ); +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/Common.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/Common.tsx new file mode 100644 index 0000000000000..b5f78d7104072 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/Common.tsx @@ -0,0 +1,59 @@ +import {Body2, Box, Colors, Skeleton, Subtitle2} from '@dagster-io/ui-components'; + +export const isEmptyChildren = (children: React.ReactNode) => + !children || (children instanceof Array && children.length === 0); + +export const AttributeAndValue = ({ + label, + children, +}: { + label: React.ReactNode; + children: React.ReactNode; +}) => { + if (isEmptyChildren(children)) { + return null; + } + + return ( + + {label} + + {children} + + + ); +}; + +export const NoValue = () => ; + +export const SectionSkeleton = () => ( + + + + + +); + +export const SectionEmptyState = ({ + title, + description, + learnMoreLink, +}: { + title: string; + description: string; + learnMoreLink: string; +}) => ( + + {title} + {description} + {learnMoreLink ? ( + + Learn more + + ) : undefined} + +); diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/ComputeDetailsSection.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/ComputeDetailsSection.tsx new file mode 100644 index 0000000000000..8fe2ff1e87552 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/ComputeDetailsSection.tsx @@ -0,0 +1,104 @@ +import {Box, ButtonLink, Colors, ConfigTypeSchema, Icon, Tag} from '@dagster-io/ui-components'; +import React from 'react'; +import {Link} from 'react-router-dom'; + +import {metadataForAssetNode} from '../AssetMetadata'; +import {AttributeAndValue, SectionSkeleton} from './Common'; +import {showCustomAlert} from '../../app/CustomAlertProvider'; +import {COMMON_COLLATOR} from '../../app/Util'; +import {DagsterTypeSummary} from '../../dagstertype/DagsterType'; +import {RepoAddress} from '../../workspace/types'; +import {workspacePathFromAddress} from '../../workspace/workspacePath'; +import {UnderlyingOpsOrGraph} from '../UnderlyingOpsOrGraph'; +import {AssetNodeDefinitionFragment} from '../types/AssetNodeDefinition.types'; + +export const ComputeDetailsSection = ({ + repoAddress, + assetNode, +}: { + repoAddress: RepoAddress | null; + assetNode: AssetNodeDefinitionFragment | null | undefined; +}) => { + if (!assetNode) { + return ; + } + const {assetType} = metadataForAssetNode(assetNode); + const configType = assetNode?.configField?.configType; + const assetConfigSchema = configType && configType.key !== 'Any' ? configType : null; + + return ( + + + + + + + + {assetNode.opVersion} + + + {[...assetNode.requiredResources] + .sort((a, b) => COMMON_COLLATOR.compare(a.resourceKey, b.resourceKey)) + .map((resource) => ( + + + + {repoAddress ? ( + + {resource.resourceKey} + + ) : ( + resource.resourceKey + )} + + + ))} + + + + {assetConfigSchema && ( + { + showCustomAlert({ + title: 'Config schema', + body: ( + + ), + }); + }} + > + View config details + + )} + + + + {assetType && assetType.displayName !== 'Any' && ( + { + showCustomAlert({ + title: 'Type summary', + body: , + }); + }} + > + View type details + + )} + + + + {assetNode.backfillPolicy?.description} + + + ); +}; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/DefinitionSection.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/DefinitionSection.tsx new file mode 100644 index 0000000000000..c5d53a674c4ac --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/DefinitionSection.tsx @@ -0,0 +1,246 @@ +import { + Box, + ButtonLink, + Caption, + Colors, + Icon, + MiddleTruncate, + Tag, + Tooltip, +} from '@dagster-io/ui-components'; +import dayjs from 'dayjs'; +import React from 'react'; +import {Link} from 'react-router-dom'; +import {UserDisplay} from 'shared/runs/UserDisplay.oss'; +import styled from 'styled-components'; + +import {AssetDefinedInMultipleReposNotice} from '../AssetDefinedInMultipleReposNotice'; +import {AttributeAndValue} from './Common'; +import {showSharedToaster} from '../../app/DomUtils'; +import {useCopyToClipboard} from '../../app/browser'; +import {CodeLink, getCodeReferenceKey} from '../../code-links/CodeLink'; +import {AssetKind, isCanonicalStorageKindTag, isSystemTag} from '../../graph/KindTags'; +import {useStateWithStorage} from '../../hooks/useStateWithStorage'; +import { + isCanonicalCodeSourceEntry, + isCanonicalTableNameEntry, + isCanonicalUriEntry, +} from '../../metadata/TableSchema'; +import {RepositoryLink} from '../../nav/RepositoryLink'; +import {buildTagString} from '../../ui/tagAsString'; +import {WorkspaceLocationNodeFragment} from '../../workspace/WorkspaceContext/types/WorkspaceQueries.types'; +import {RepoAddress} from '../../workspace/types'; +import {workspacePathFromAddress} from '../../workspace/workspacePath'; +import {AssetNodeDefinitionFragment} from '../types/AssetNodeDefinition.types'; +import {AssetTableDefinitionFragment} from '../types/AssetTableFragment.types'; + +export const DefinitionSection = ({ + repoAddress, + location, + assetNode, + cachedOrLiveAssetNode, +}: { + repoAddress: RepoAddress | null; + location: WorkspaceLocationNodeFragment | undefined; + assetNode: AssetNodeDefinitionFragment | null | undefined; + cachedOrLiveAssetNode: AssetNodeDefinitionFragment | AssetTableDefinitionFragment; +}) => { + const storageKindTag = cachedOrLiveAssetNode.tags?.find(isCanonicalStorageKindTag); + const filteredTags = cachedOrLiveAssetNode.tags?.filter( + (tag) => tag.key !== 'dagster/storage_kind', + ); + + const nonSystemTags = filteredTags?.filter((tag) => !isSystemTag(tag)); + const systemTags = filteredTags?.filter(isSystemTag); + + const tableNameMetadata = assetNode?.metadataEntries?.find(isCanonicalTableNameEntry); + const uriMetadata = assetNode?.metadataEntries?.find(isCanonicalUriEntry); + const codeSource = assetNode?.metadataEntries?.find(isCanonicalCodeSourceEntry); + + return ( + + + + + {cachedOrLiveAssetNode.groupName} + + + + + + + + + {location && ( + + Loaded {dayjs.unix(location.updatedTimestamp).fromNow()} + + )} + + + + {cachedOrLiveAssetNode.owners && + cachedOrLiveAssetNode.owners.length > 0 && + cachedOrLiveAssetNode.owners.map((owner, idx) => + owner.__typename === 'UserAssetOwner' ? ( + + + + ) : ( + + {owner.team} + + ), + )} + + + {cachedOrLiveAssetNode.computeKind && ( + + )} + + + {(cachedOrLiveAssetNode.kinds.length > 1 || !cachedOrLiveAssetNode.computeKind) && + cachedOrLiveAssetNode.kinds.map((kind) => ( + + ))} + + + {(tableNameMetadata || uriMetadata || storageKindTag) && ( + + {tableNameMetadata && ( + + + + + )} + {uriMetadata && ( + + {uriMetadata.__typename === 'TextMetadataEntry' ? ( + uriMetadata.text + ) : ( + + {uriMetadata.url} + + )} + + + )} + {storageKindTag && ( + + )} + + )} + + + {filteredTags && filteredTags.length > 0 && ( + + + {nonSystemTags.map((tag, idx) => ( + {buildTagString(tag)} + ))} + + {systemTags.length > 0 && } + + )} + + + {codeSource && + codeSource.codeReferences && + codeSource.codeReferences.map((ref) => ( + + ))} + + + ); +}; + +const SystemTagsToggle = ({tags}: {tags: Array<{key: string; value: string}>}) => { + const [shown, setShown] = useStateWithStorage('show-asset-definition-system-tags', Boolean); + + if (!shown) { + return ( + + setShown(true)}> + + Show system tags ({tags.length || 0}) + + + + + ); + } else { + return ( + + + {tags.map((tag, idx) => ( + {buildTagString(tag)} + ))} + + + setShown(false)}> + + Hide system tags + + + + + + ); + } +}; + +const CopyButton = ({value}: {value: string}) => { + const copy = useCopyToClipboard(); + const onCopy = async () => { + copy(value); + await showSharedToaster({ + intent: 'success', + icon: 'copy_to_clipboard_done', + message: 'Copied!', + }); + }; + + return ( + +
+ +
+
+ ); +}; + +const UserAssetOwnerWrapper = styled.div` + > div { + background-color: ${Colors.backgroundGray()}; + } +`; diff --git a/js_modules/dagster-ui/packages/ui-core/src/assets/overview/LineageSection.tsx b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/LineageSection.tsx new file mode 100644 index 0000000000000..58180ae2cc721 --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/assets/overview/LineageSection.tsx @@ -0,0 +1,99 @@ +import {Box, Button, MiddleTruncate, Subtitle2} from '@dagster-io/ui-components'; +import React, {useState} from 'react'; +import {Link} from 'react-router-dom'; + +import {NoValue} from './Common'; +import {displayNameForAssetKey, sortAssetKeys, tokenForAssetKey} from '../../asset-graph/Utils'; +import {StatusDot} from '../../asset-graph/sidebar/StatusDot'; +import {AssetNodeForGraphQueryFragment} from '../../asset-graph/types/useAssetGraphData.types'; +import {DependsOnSelfBanner} from '../DependsOnSelfBanner'; +import {assetDetailsPathForKey} from '../assetDetailsPathForKey'; + +export const LineageSection = ({ + dependsOnSelf, + upstream, + downstream, +}: { + upstream: AssetNodeForGraphQueryFragment[] | null; + downstream: AssetNodeForGraphQueryFragment[] | null; + dependsOnSelf: boolean; +}) => { + return ( + <> + {dependsOnSelf && ( + + + + )} + + + + Upstream assets + {upstream?.length ? ( + + ) : ( + + + + )} + + + Downstream assets + {downstream?.length ? ( + + ) : ( + + + + )} + + + + ); +}; + +const AssetLinksWithStatus = ({ + assets, + displayedByDefault = 20, +}: { + assets: AssetNodeForGraphQueryFragment[]; + displayedByDefault?: number; +}) => { + const [displayedCount, setDisplayedCount] = useState(displayedByDefault); + + const displayed = React.useMemo( + () => assets.sort((a, b) => sortAssetKeys(a.assetKey, b.assetKey)).slice(0, displayedCount), + [assets, displayedCount], + ); + + return ( + + {displayed.map((asset) => ( + +
+ + +
+ + ))} + + {displayed.length < assets.length ? ( + + ) : displayed.length > displayedByDefault ? ( + + ) : undefined} + +
+ ); +};