feat(ingest): change asset list (#14073)

This commit is contained in:
Aseem Bansal 2025-07-22 14:41:01 +05:30 committed by GitHub
parent b8aac8b4e3
commit e539e33e10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1428 additions and 316 deletions

View File

@ -16,7 +16,14 @@
"Bash(ruff:*)",
"Bash(python -m mypy:*)",
"Bash(python -m ruff:*)",
"Bash(python -m pytest:*)"
"Bash(python -m pytest:*)",
"Bash(yarn format:*)",
"Bash(yarn lint:*)",
"Bash(yarn type-check:*)",
"Bash(yarn test:*)",
"Bash(yarn generate:*)",
"Bash(./gradlew :datahub-web-react:yarnLintFix)",
"Bash(./gradlew :datahub-web-react:yarnLint)"
],
"deny": []
}

View File

@ -174,8 +174,19 @@ yarn generate
# Run formatting
yarn format
# Run linting
yarn lint
# Run linting on all files
../gradlew yarnLint
# Run lint-fix on a single file
../gradlew -x yarnInstall -x yarnGenerate yarnLintFix -Pfile=src/path/to/file.tsx
# Run linting on a single file
# This does not run full type-check when we run for a single file
# that should be run at the end of all changes before commit
../gradlew -x yarnInstall -x yarnGenerate yarnLint -Pfile=src/path/to/file.tsx
# Run lint-fix on all files
../gradlew yarnLintFix
# Run type checking
yarn type-check

View File

@ -105,12 +105,38 @@ task yarnTest(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) {
}
task yarnLint(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) {
def targetFile = project.findProperty('file') ?: ''
if (targetFile.isEmpty()) {
// Run on all files
args = ['run', 'lint']
} else {
// Run on specific file - run lint, format-check, and type-check
doFirst {
// Run lint-file first
exec {
workingDir projectDir
commandLine 'yarn', 'run', 'lint-file', targetFile
}
// Then run format-check-file
exec {
workingDir projectDir
commandLine 'yarn', 'run', 'format-check-file', targetFile
}
// This does not run full type-check when we run for a single file
// as that would take too long
}
outputs.cacheIf { false }
}
}
test.dependsOn([yarnInstall, yarnTest, yarnLint])
task yarnLintFix(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) {
def targetFile = project.findProperty('file') ?: ''
if (targetFile.isEmpty()) {
// Run on all files
args = ['run', 'lint-fix']
def lint_sentinel = "${buildDir}/.yarn-lint-sentinel"
outputs.file(lint_sentinel)
@ -125,6 +151,22 @@ task yarnLintFix(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) {
}
}
outputs.cacheIf { true }
} else {
// Run on specific file - need to run both lint-fix and format
doFirst {
// Run lint-fix-file first
exec {
workingDir projectDir
commandLine 'yarn', 'run', 'lint-fix-file', targetFile
}
// Then run format-file
exec {
workingDir projectDir
commandLine 'yarn', 'run', 'format-file', targetFile
}
}
outputs.cacheIf { false }
}
}
task yarnBuild(type: YarnTask, dependsOn: [yarnInstall, yarnGenerate]) {

View File

@ -115,8 +115,12 @@
"generate": "NODE_OPTIONS='--max-old-space-size=5120 --openssl-legacy-provider' graphql-codegen --config codegen.yml",
"lint": "eslint . --ext .ts,.tsx --quiet && yarn format-check && yarn type-check",
"lint-fix": "eslint . --ext .ts,.tsx --quiet --fix && yarn format",
"lint-file": "eslint --ext .ts,.tsx --quiet",
"format-check-file": "prettier --check",
"lint-fix-file": "eslint --ext .ts,.tsx --quiet --fix",
"format-check": "prettier --check src",
"format": "prettier --write src",
"format-file": "prettier --write",
"type-check": "tsc --noEmit",
"type-watch": "tsc -w --noEmit",
"storybook": "storybook dev -p 6006",

View File

@ -42,6 +42,12 @@ const HeaderContainer = styled.div<{ hasChildren: boolean }>`
flex-direction: column;
`;
const TitleRow = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
const ButtonsContainer = styled.div`
display: flex;
gap: 16px;
@ -59,6 +65,7 @@ export interface ModalProps {
buttons: ModalButton[];
title: string;
subtitle?: string;
titlePill?: React.ReactNode;
children?: React.ReactNode;
onCancel: () => void;
dataTestId?: string;
@ -68,6 +75,7 @@ export function Modal({
buttons,
title,
subtitle,
titlePill,
children,
onCancel,
dataTestId,
@ -83,9 +91,12 @@ export function Modal({
data-testid={dataTestId}
title={
<HeaderContainer hasChildren={!!children}>
<TitleRow>
<Heading type="h1" color="gray" colorLevel={600} weight="bold" size="lg">
{title}
</Heading>
{titlePill}
</TitleRow>
{!!subtitle && (
<Text type="span" color="gray" colorLevel={1700} weight="medium">
{subtitle}

View File

@ -90,6 +90,19 @@ const meta = {
getCurrentUrl: {
description: 'A custom function to get the current URL. Defaults to window.location.pathname',
},
scrollToTopOnChange: {
description: 'Whether to scroll to the top of the tabs container when switching tabs',
control: { type: 'boolean' },
},
maxHeight: {
description:
'Maximum height of the scrollable tabs container (only applies when scrollToTopOnChange is true)',
control: { type: 'text' },
},
stickyHeader: {
description: 'Whether to make the tab headers sticky when scrolling',
control: { type: 'boolean' },
},
},
// Args for the story
@ -136,3 +149,77 @@ export const urlAware: Story = {
tags: ['dev'],
render: () => <UrlAwareTabsDemo />,
};
const StickyHeaderTabsDemo = () => {
const [selectedTab, setSelectedTab] = React.useState('tab1');
const stickyTabs = [
{
key: 'tab1',
name: 'Tab One',
component: (
<div style={{ padding: '20px' }}>
<h3>Tab One with Sticky Header</h3>
{Array.from({ length: 40 }, (_, i) => (
<p key={i}>
This is paragraph {i + 1}. The tab header will stick to the top when you scroll down. Lorem
ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua.
</p>
))}
</div>
),
},
{
key: 'tab2',
name: 'Tab Two',
component: (
<div style={{ padding: '20px' }}>
<h3>Tab Two Content</h3>
{Array.from({ length: 35 }, (_, i) => (
<div key={i} style={{ marginBottom: '15px', padding: '15px', backgroundColor: '#f0f0f0' }}>
Content block {i + 1} - Notice how the tab header remains visible at the top while
scrolling.
</div>
))}
</div>
),
},
{
key: 'tab3',
name: 'Tab Three',
component: (
<div style={{ padding: '20px' }}>
<h3>Tab Three Content</h3>
{Array.from({ length: 50 }, (_, i) => (
<p key={i}>
Line {i + 1}: The sticky header allows you to easily switch between tabs even when scrolled
deep into the content. This is particularly useful for long content like logs or detailed
configuration files.
</p>
))}
</div>
),
},
];
return (
<div style={{ width: '700px', height: '500px' }}>
<h4>Sticky Header & Scroll to Top on Tab Change Demo</h4>
<p>Scroll down within the tab content to see the sticky header behavior and switch between tabs</p>
<Tabs
tabs={stickyTabs}
selectedTab={selectedTab}
onChange={setSelectedTab}
scrollToTopOnChange
maxHeight="400px"
stickyHeader
/>
</div>
);
};
export const stickyHeader: Story = {
tags: ['dev'],
render: () => <StickyHeaderTabsDemo />,
};

View File

@ -1,5 +1,5 @@
import { Tabs as AntTabs } from 'antd';
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { Pill } from '@components/components/Pills';
@ -7,9 +7,21 @@ import { Tooltip } from '@components/components/Tooltip';
import { colors } from '@src/alchemy-components/theme';
const StyledTabs = styled(AntTabs)<{ $addPaddingLeft?: boolean; $hideTabsHeader: boolean }>`
flex: 1;
overflow: hidden;
const ScrollableTabsContainer = styled.div<{ $maxHeight?: string }>`
max-height: ${({ $maxHeight }) => $maxHeight || '100%'};
overflow-y: auto;
height: 100%;
position: relative;
`;
const StyledTabs = styled(AntTabs)<{
$addPaddingLeft?: boolean;
$hideTabsHeader: boolean;
$scrollable?: boolean;
$stickyHeader?: boolean;
}>`
${({ $scrollable }) => !$scrollable && 'flex: 1;'}
${({ $scrollable }) => !$scrollable && 'overflow: hidden;'}
.ant-tabs-tab {
padding: 8px 0;
@ -38,6 +50,17 @@ const StyledTabs = styled(AntTabs)<{ $addPaddingLeft?: boolean; $hideTabsHeader:
}
`}
${({ $stickyHeader }) =>
$stickyHeader &&
`
.ant-tabs-nav {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
}
`}
.ant-tabs-tab-active .ant-tabs-tab-btn {
color: ${(props) => props.theme.styles['primary-color']};
font-weight: 600;
@ -58,6 +81,37 @@ const StyledTabs = styled(AntTabs)<{ $addPaddingLeft?: boolean; $hideTabsHeader:
.ant-tabs-nav {
margin-bottom: 24px;
}
${({ $stickyHeader }) =>
$stickyHeader &&
`
.ant-tabs-nav::before {
display: none;
}
.ant-tabs-nav-wrap::before {
display: none;
}
.ant-tabs-nav-list::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background-color: ${colors.gray[200]};
}
`}
${({ $addPaddingLeft, $stickyHeader }) =>
$addPaddingLeft &&
$stickyHeader &&
`
.ant-tabs-nav-list::after {
left: 16px;
}
`}
`;
const TabViewWrapper = styled.div<{ $disabled?: boolean }>`
@ -100,6 +154,9 @@ export interface Props {
getCurrentUrl?: () => string;
addPaddingLeft?: boolean;
hideTabsHeader?: boolean;
scrollToTopOnChange?: boolean;
maxHeight?: string;
stickyHeader?: boolean;
}
export function Tabs({
@ -112,8 +169,19 @@ export function Tabs({
getCurrentUrl = () => window.location.pathname,
addPaddingLeft,
hideTabsHeader,
scrollToTopOnChange = false,
maxHeight = '100%',
stickyHeader = false,
}: Props) {
const { TabPane } = AntTabs;
const tabsContainerRef = useRef<HTMLDivElement>(null);
// Scroll to top when selectedTab changes if scrollToTopOnChange is enabled
useEffect(() => {
if (scrollToTopOnChange && tabsContainerRef.current) {
tabsContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [selectedTab, scrollToTopOnChange]);
// Create reverse mapping from URLs to tab keys if urlMap is provided
const urlToTabMap = React.useMemo(() => {
@ -140,23 +208,19 @@ export function Tabs({
}
}, [getCurrentUrl, onChange, onUrlChange, selectedTab, urlMap, urlToTabMap, defaultTab]);
function handleTabClick(key: string) {
onChange?.(key);
const newTab = tabs.find((t) => t.key === key);
newTab?.onSelectTab?.();
// Update URL if urlMap is provided
if (urlMap && urlMap[key]) {
onUrlChange(urlMap[key]);
}
}
return (
const tabsContent = (
<StyledTabs
activeKey={selectedTab}
onChange={handleTabClick}
onChange={(key) => {
if (onChange) onChange(key);
if (urlMap && onUrlChange && urlMap[key]) {
onUrlChange(urlMap[key]);
}
}}
$addPaddingLeft={addPaddingLeft}
$hideTabsHeader={!!hideTabsHeader}
$scrollable={scrollToTopOnChange}
$stickyHeader={stickyHeader}
>
{tabs.map((tab) => {
return (
@ -167,4 +231,14 @@ export function Tabs({
})}
</StyledTabs>
);
if (scrollToTopOnChange) {
return (
<ScrollableTabsContainer ref={tabsContainerRef} $maxHeight={maxHeight}>
{tabsContent}
</ScrollableTabsContainer>
);
}
return tabsContent;
}

View File

@ -1,5 +1,5 @@
import { Button, PageTitle, Tabs, Tooltip } from '@components';
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router';
import styled from 'styled-components';
@ -112,16 +112,6 @@ export const ManageIngestionPage = () => {
}
}, [loadedAppConfig, loadedPlatformPrivileges, showIngestionTab, showSecretsTab, selectedTab]);
const onSwitchTab = (newTab: string, options?: { clearQueryParams: boolean }) => {
const preserveParams = shouldPreserveParams.current;
const matchingTab = Object.values(TabType).find((tab) => tab === newTab);
if (!preserveParams && options?.clearQueryParams) {
history.push({ search: '' });
}
setSelectedTab(matchingTab || selectedTab);
shouldPreserveParams.current = false;
};
const tabs: Tab[] = [
showIngestionTab && {
component: (
@ -160,9 +150,14 @@ export const ManageIngestionPage = () => {
},
].filter((tab): tab is Tab => Boolean(tab));
const onUrlChange = (tabPath: string) => {
history.push(tabPath);
};
const onUrlChange = useCallback(
(tabPath: string) => {
history.push({ pathname: tabPath, search: '' });
},
[history],
);
const getCurrentUrl = useCallback(() => window.location.pathname, []);
const handleCreateSource = () => {
setShowCreateSourceModal(true);
@ -228,11 +223,11 @@ export const ManageIngestionPage = () => {
<Tabs
tabs={tabs}
selectedTab={selectedTab}
onChange={(tab) => onSwitchTab(tab, { clearQueryParams: true })}
onChange={(tab) => setSelectedTab(tab as TabType)}
urlMap={tabUrlMap}
onUrlChange={onUrlChange}
defaultTab={TabType.Sources}
getCurrentUrl={() => window.location.pathname}
getCurrentUrl={getCurrentUrl}
/>
</PageContentContainer>
</PageContainer>

View File

@ -0,0 +1,14 @@
import { Typography } from 'antd';
import styled from 'styled-components';
export const SectionBase = styled.div`
padding: 16px 30px 0;
`;
export const SectionHeader = styled(Typography.Title)`
&&&& {
padding: 0px;
margin: 0px;
margin-bottom: 12px;
}
`;

View File

@ -1,111 +1,30 @@
import { DownloadOutlined, LoadingOutlined } from '@ant-design/icons';
import { LoadingOutlined } from '@ant-design/icons';
import { Icon, Modal, Pill } from '@components';
import { Button, Typography, message } from 'antd';
import { message } from 'antd';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import YAML from 'yamljs';
import { ANTD_GRAY } from '@app/entity/shared/constants';
import { StructuredReport } from '@app/ingestV2/executions/components/reporting/StructuredReport';
import {
EXECUTION_REQUEST_STATUS_LOADING,
EXECUTION_REQUEST_STATUS_RUNNING,
EXECUTION_REQUEST_STATUS_SUCCEEDED_WITH_WARNINGS,
EXECUTION_REQUEST_STATUS_SUCCESS,
} from '@app/ingestV2/executions/constants';
import { Tab, Tabs } from '@components/components/Tabs/Tabs';
import { LogsTab } from '@app/ingestV2/executions/components/LogsTab';
import { RecipeTab } from '@app/ingestV2/executions/components/RecipeTab';
import { SummaryTab } from '@app/ingestV2/executions/components/SummaryTab';
import { EXECUTION_REQUEST_STATUS_LOADING, EXECUTION_REQUEST_STATUS_RUNNING } from '@app/ingestV2/executions/constants';
import { TabType } from '@app/ingestV2/executions/types';
import {
getExecutionRequestStatusDisplayColor,
getExecutionRequestStatusDisplayText,
getExecutionRequestStatusIcon,
getExecutionRequestSummaryText,
} from '@app/ingestV2/executions/utils';
import IngestedAssets from '@app/ingestV2/source/IngestedAssets';
import { getIngestionSourceStatus, getStructuredReport } from '@app/ingestV2/source/utils';
import { downloadFile } from '@app/search/utils/csvUtils';
import { getIngestionSourceStatus } from '@app/ingestV2/source/utils';
import { Message } from '@app/shared/Message';
import { useGetIngestionExecutionRequestQuery } from '@graphql/ingestion.generated';
import { ExecutionRequestResult } from '@types';
const Section = styled.div`
display: flex;
flex-direction: column;
padding-bottom: 12px;
`;
const SectionHeader = styled(Typography.Title)`
&&&& {
padding: 0px;
margin: 0px;
margin-bottom: 12px;
}
`;
const SectionSubHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const SubHeaderParagraph = styled(Typography.Paragraph)`
margin-bottom: 0px;
`;
const StatusSection = styled.div`
border-bottom: 1px solid ${ANTD_GRAY[4]};
padding: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const ResultText = styled.div`
margin-bottom: 4px;
`;
const IngestedAssetsSection = styled.div`
border-bottom: 1px solid ${ANTD_GRAY[4]};
padding: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const RecipeSection = styled.div`
border-top: 1px solid ${ANTD_GRAY[4]};
padding-top: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const LogsSection = styled.div`
padding-top: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const ShowMoreButton = styled(Button)`
padding: 0px;
`;
const DetailsContainer = styled.div<DetailsContainerProps>`
margin-bottom: -25px;
${(props) =>
props.areDetailsExpandable &&
!props.showExpandedDetails &&
`
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(255,0,0,0.5) 60%, rgba(255,0,0,0) 90% );
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 50%, rgba(255,0,0,0.5) 60%, rgba(255,0,0,0) 90%);
`}
`;
const modalBodyStyle = {
padding: 0,
};
type DetailsContainerProps = {
showExpandedDetails: boolean;
areDetailsExpandable: boolean;
};
type Props = {
urn: string;
open: boolean;
@ -113,33 +32,14 @@ type Props = {
};
export const ExecutionDetailsModal = ({ urn, open, onClose }: Props) => {
const [showExpandedLogs, setShowExpandedLogs] = useState(false);
const [showExpandedRecipe, setShowExpandedRecipe] = useState(false);
const { data, loading, error, refetch } = useGetIngestionExecutionRequestQuery({ variables: { urn } });
const output = data?.executionRequest?.result?.report || 'No output found.';
const downloadLogs = () => {
downloadFile(output, `exec-${urn}.log`);
};
const logs = (showExpandedLogs && output) || output?.split('\n')?.slice(0, 5)?.join('\n');
const result = data?.executionRequest?.result as Partial<ExecutionRequestResult>;
const status = getIngestionSourceStatus(result);
useEffect(() => {
const interval = setInterval(() => {
if (status === EXECUTION_REQUEST_STATUS_RUNNING) refetch();
}, 2000);
return () => clearInterval(interval);
});
const [selectedTab, setSelectedTab] = useState<TabType>(TabType.Summary);
const ResultIcon = status && getExecutionRequestStatusIcon(status);
const resultColor = status ? getExecutionRequestStatusDisplayColor(status) : 'gray';
const resultText = status && (
<Typography.Text style={{ color: resultColor, fontSize: 14 }}>
{ResultIcon && (
const titlePill = status && ResultIcon && (
<Pill
customIconRenderer={() =>
status === EXECUTION_REQUEST_STATUS_LOADING || status === EXECUTION_REQUEST_STATUS_RUNNING ? (
@ -152,100 +52,64 @@ export const ExecutionDetailsModal = ({ urn, open, onClose }: Props) => {
color={resultColor}
size="md"
/>
)}
</Typography.Text>
);
const structuredReport = result && getStructuredReport(result);
useEffect(() => {
const interval = setInterval(() => {
if (status === EXECUTION_REQUEST_STATUS_RUNNING) refetch();
}, 2000);
const resultSummaryText =
(status && <Typography.Text type="secondary">{getExecutionRequestSummaryText(status)}</Typography.Text>) ||
undefined;
return () => clearInterval(interval);
});
const recipeJson = data?.executionRequest?.input?.arguments?.find((arg) => arg.key === 'recipe')?.value;
let recipeYaml: string;
try {
recipeYaml = recipeJson && YAML.stringify(JSON.parse(recipeJson), 8, 2).trim();
} catch (e) {
recipeYaml = '';
}
const recipe = showExpandedRecipe ? recipeYaml : recipeYaml?.split('\n')?.slice(0, 5)?.join('\n');
const areLogsExpandable = output?.split(/\r\n|\r|\n/)?.length > 5;
const isRecipeExpandable = recipeYaml?.split(/\r\n|\r|\n/)?.length > 5;
const tabs: Tab[] = [
{
component: (
<SummaryTab
urn={urn}
status={status}
result={result}
data={data}
onTabChange={(tab: TabType) => setSelectedTab(tab)}
/>
),
key: TabType.Summary,
name: TabType.Summary,
},
{
component: <LogsTab urn={urn} data={data} />,
key: TabType.Logs,
name: TabType.Logs,
},
{
component: <RecipeTab data={data} />,
key: TabType.Recipe,
name: TabType.Recipe,
},
];
return (
<Modal
width={800}
width="1400px"
bodyStyle={modalBodyStyle}
title="Execution Run Details"
title="Status Details"
titlePill={titlePill}
open={open}
onCancel={onClose}
buttons={[{ text: 'Close', variant: 'outline', onClick: onClose }]}
>
{!data && loading && <Message type="loading" content="Loading execution run details..." />}
{error && message.error('Failed to load execution run details :(')}
<Section>
<StatusSection>
<Typography.Title level={5}>Status</Typography.Title>
<ResultText>{resultText}</ResultText>
<SubHeaderParagraph>{resultSummaryText}</SubHeaderParagraph>
{structuredReport ? <StructuredReport report={structuredReport} /> : null}
</StatusSection>
{(status === EXECUTION_REQUEST_STATUS_SUCCESS ||
status === EXECUTION_REQUEST_STATUS_SUCCEEDED_WITH_WARNINGS) && (
<IngestedAssetsSection>
{data?.executionRequest?.id && (
<IngestedAssets executionResult={result} id={data?.executionRequest?.id} />
)}
</IngestedAssetsSection>
)}
<LogsSection>
<SectionHeader level={5}>Logs</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">
View logs that were collected during the sync.
</SubHeaderParagraph>
<Button type="text" onClick={downloadLogs}>
<DownloadOutlined />
Download
</Button>
</SectionSubHeader>
<DetailsContainer areDetailsExpandable={areLogsExpandable} showExpandedDetails={showExpandedLogs}>
<Typography.Paragraph ellipsis>
<pre>{`${logs}${!showExpandedLogs && areLogsExpandable ? '...' : ''}`}</pre>
</Typography.Paragraph>
</DetailsContainer>
{areLogsExpandable && (
<ShowMoreButton type="link" onClick={() => setShowExpandedLogs(!showExpandedLogs)}>
{showExpandedLogs ? 'Hide' : 'Show More'}
</ShowMoreButton>
)}
</LogsSection>
{recipe && (
<RecipeSection>
<SectionHeader level={5}>Recipe</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">
The configurations used for this sync with the data source.
</SubHeaderParagraph>
</SectionSubHeader>
<DetailsContainer
areDetailsExpandable={isRecipeExpandable}
showExpandedDetails={showExpandedRecipe}
>
<Typography.Paragraph ellipsis>
<pre>{`${recipe}${!showExpandedRecipe && isRecipeExpandable ? '...' : ''}`}</pre>
</Typography.Paragraph>
</DetailsContainer>
{isRecipeExpandable && (
<ShowMoreButton type="link" onClick={() => setShowExpandedRecipe((v) => !v)}>
{showExpandedRecipe ? 'Hide' : 'Show More'}
</ShowMoreButton>
)}
</RecipeSection>
)}
</Section>
<Tabs
tabs={tabs}
selectedTab={selectedTab}
onChange={(tab) => setSelectedTab(tab as TabType)}
getCurrentUrl={() => window.location.pathname}
scrollToTopOnChange
maxHeight="80vh"
stickyHeader
addPaddingLeft
/>
</Modal>
);
};

View File

@ -0,0 +1,49 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { SectionHeader } from '@app/ingestV2/executions/components/BaseTab';
import { downloadFile } from '@app/search/utils/csvUtils';
import { GetIngestionExecutionRequestQuery } from '@graphql/ingestion.generated';
const SectionSubHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const SubHeaderParagraph = styled(Typography.Paragraph)`
margin-bottom: 0px;
`;
const LogsSection = styled.div`
padding-top: 16px;
padding-left: 30px;
padding-right: 30px;
`;
export const LogsTab = ({ urn, data }: { urn: string; data: GetIngestionExecutionRequestQuery | undefined }) => {
const output = data?.executionRequest?.result?.report || 'No output found.';
const downloadLogs = () => {
downloadFile(output, `exec-${urn}.log`);
};
return (
<LogsSection>
<SectionHeader level={5}>Logs</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">View logs that were collected during the sync.</SubHeaderParagraph>
<Button type="text" onClick={downloadLogs}>
<DownloadOutlined />
Download
</Button>
</SectionSubHeader>
<Typography.Paragraph ellipsis>
<pre>{output}</pre>
</Typography.Paragraph>
</LogsSection>
);
};

View File

@ -0,0 +1,47 @@
import { Typography } from 'antd';
import React from 'react';
import styled from 'styled-components';
import YAML from 'yamljs';
import { SectionBase, SectionHeader } from '@app/ingestV2/executions/components/BaseTab';
import colors from '@src/alchemy-components/theme/foundations/colors';
import { GetIngestionExecutionRequestQuery } from '@graphql/ingestion.generated';
const SectionSubHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const SubHeaderParagraph = styled(Typography.Paragraph)`
margin-bottom: 0px;
`;
const RecipeSection = styled(SectionBase)`
border-top: 1px solid ${colors.gray[1400]};
`;
export const RecipeTab = ({ data }: { data: GetIngestionExecutionRequestQuery | undefined }) => {
const recipeJson = data?.executionRequest?.input?.arguments?.find((arg) => arg.key === 'recipe')?.value;
let recipeYaml: string;
try {
recipeYaml = recipeJson && YAML.stringify(JSON.parse(recipeJson), 8, 2).trim();
} catch (e) {
recipeYaml = '';
}
return (
<RecipeSection>
<SectionHeader level={5}>Recipe</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">
The configurations used for this sync with the data source.
</SubHeaderParagraph>
</SectionSubHeader>
<Typography.Paragraph ellipsis>
<pre>{recipeYaml || 'No recipe found.'}</pre>
</Typography.Paragraph>
</RecipeSection>
);
};

View File

@ -0,0 +1,187 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Button, Typography } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import YAML from 'yamljs';
import { SectionBase, SectionHeader } from '@app/ingestV2/executions/components/BaseTab';
import { StructuredReport, hasSomethingToShow } from '@app/ingestV2/executions/components/reporting/StructuredReport';
import { EXECUTION_REQUEST_STATUS_SUCCESS } from '@app/ingestV2/executions/constants';
import { TabType } from '@app/ingestV2/executions/types';
import { getExecutionRequestSummaryText } from '@app/ingestV2/executions/utils';
import IngestedAssets from '@app/ingestV2/source/IngestedAssets';
import { getStructuredReport } from '@app/ingestV2/source/utils';
import { downloadFile } from '@app/search/utils/csvUtils';
import colors from '@src/alchemy-components/theme/foundations/colors';
import { GetIngestionExecutionRequestQuery } from '@graphql/ingestion.generated';
import { ExecutionRequestResult } from '@types';
const Section = styled.div`
display: flex;
flex-direction: column;
padding-bottom: 12px;
`;
const SectionSubHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
`;
const ButtonGroup = styled.div`
display: flex;
gap: 8px;
align-items: center;
`;
const SubHeaderParagraph = styled(Typography.Paragraph)`
margin-bottom: 0px;
`;
const StatusSection = styled.div`
border-bottom: 1px solid ${colors.gray[1400]};
padding: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const IngestedAssetsSection = styled.div<{ isFirstSection?: boolean }>`
border-bottom: 1px solid ${colors.gray[1400]};
${({ isFirstSection }) => !isFirstSection && `border-top: 1px solid ${colors.gray[1400]};`}
padding: 16px;
padding-left: 30px;
padding-right: 30px;
`;
const RecipeSection = styled(SectionBase)``;
const LogsSection = styled(SectionBase)``;
const ShowMoreButton = styled(Button)`
padding: 0px;
`;
const DetailsContainer = styled.div`
margin-top: 12px;
pre {
background-color: ${colors.gray[1500]};
border: 1px solid ${colors.gray[1400]};
border-radius: 8px;
padding: 16px;
margin: 0;
color: ${colors.gray[1700]};
}
`;
export const SummaryTab = ({
urn,
status,
result,
data,
onTabChange,
}: {
urn: string;
status: string | undefined;
result: Partial<ExecutionRequestResult>;
data: GetIngestionExecutionRequestQuery | undefined;
onTabChange: (tab: TabType) => void;
}) => {
const [showExpandedLogs] = useState(false);
const [showExpandedRecipe] = useState(false);
const output = data?.executionRequest?.result?.report || 'No output found.';
const downloadLogs = () => {
downloadFile(output, `exec-${urn}.log`);
};
const logs = (showExpandedLogs && output) || output?.split('\n')?.slice(0, 14)?.join('\n');
const structuredReport = result && getStructuredReport(result);
const resultSummaryText =
(status && status !== EXECUTION_REQUEST_STATUS_SUCCESS && (
<Typography.Text type="secondary">{getExecutionRequestSummaryText(status)}</Typography.Text>
)) ||
undefined;
const recipeJson = data?.executionRequest?.input?.arguments?.find((arg) => arg.key === 'recipe')?.value;
let recipeYaml: string;
try {
recipeYaml = recipeJson && YAML.stringify(JSON.parse(recipeJson), 8, 2).trim();
} catch (e) {
recipeYaml = '';
}
const recipe = showExpandedRecipe ? recipeYaml : recipeYaml?.split('\n')?.slice(0, 14)?.join('\n');
const areLogsExpandable = output?.split(/\r\n|\r|\n/)?.length > 14;
const isRecipeExpandable = recipeYaml?.split(/\r\n|\r|\n/)?.length > 14;
const downloadRecipe = () => {
downloadFile(recipeYaml, `recipe-${urn}.yaml`);
};
return (
<Section>
{(resultSummaryText || (structuredReport && hasSomethingToShow(structuredReport))) && (
<StatusSection>
<SubHeaderParagraph>{resultSummaryText}</SubHeaderParagraph>
{structuredReport && structuredReport ? <StructuredReport report={structuredReport} /> : null}
</StatusSection>
)}
<IngestedAssetsSection
isFirstSection={!resultSummaryText && !(structuredReport && hasSomethingToShow(structuredReport))}
>
{data?.executionRequest?.id && (
<IngestedAssets executionResult={result} id={data?.executionRequest?.id} />
)}
</IngestedAssetsSection>
<LogsSection>
<SectionHeader level={5}>Logs</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">
View logs that were collected during the sync.
</SubHeaderParagraph>
<ButtonGroup>
{areLogsExpandable && (
<ShowMoreButton type="text" onClick={() => onTabChange(TabType.Logs)}>
View More
</ShowMoreButton>
)}
<Button type="text" onClick={downloadLogs}>
<DownloadOutlined />
</Button>
</ButtonGroup>
</SectionSubHeader>
<DetailsContainer>
<Typography.Paragraph ellipsis>
<pre>{`${logs}${areLogsExpandable ? '...' : ''}`}</pre>
</Typography.Paragraph>
</DetailsContainer>
</LogsSection>
{recipe && (
<RecipeSection>
<SectionHeader level={5}>Recipe</SectionHeader>
<SectionSubHeader>
<SubHeaderParagraph type="secondary">
The configurations used for this sync with the data source.
</SubHeaderParagraph>
<ButtonGroup>
{isRecipeExpandable && (
<ShowMoreButton type="text" onClick={() => onTabChange(TabType.Recipe)}>
View More
</ShowMoreButton>
)}
<Button type="text" onClick={downloadRecipe}>
<DownloadOutlined />
</Button>
</ButtonGroup>
</SectionSubHeader>
<DetailsContainer>
<Typography.Paragraph ellipsis>
<pre>{`${recipe}${!showExpandedRecipe && isRecipeExpandable ? '...' : ''}`}</pre>
</Typography.Paragraph>
</DetailsContainer>
</RecipeSection>
)}
</Section>
);
};

View File

@ -23,6 +23,13 @@ interface Props {
report: StructuredReportType;
}
export function hasSomethingToShow(report: StructuredReportType): boolean {
const warnings = report.items.filter((item) => item.level === StructuredReportItemLevel.WARN);
const errors = report.items.filter((item) => item.level === StructuredReportItemLevel.ERROR);
const infos = report.items.filter((item) => item.level === StructuredReportItemLevel.INFO);
return warnings.length > 0 || errors.length > 0 || infos.length > 0;
}
export function StructuredReport({ report }: Props) {
if (!report.items.length) {
return null;
@ -34,7 +41,12 @@ export function StructuredReport({ report }: Props) {
return (
<Container>
{errors.length ? (
<StructuredReportItemList items={errors} color={ERROR_COLOR} icon={CloseCircleOutlined} />
<StructuredReportItemList
items={errors}
color={ERROR_COLOR}
icon={CloseCircleOutlined}
defaultActiveKey="0"
/>
) : null}
{warnings.length ? (
<StructuredReportItemList items={warnings} color={WARNING_COLOR} icon={ExclamationCircleOutlined} />

View File

@ -55,12 +55,13 @@ interface Props {
item: StructuredReportLogEntry;
color: string;
icon?: React.ComponentType<any>;
defaultActiveKey?: string;
}
export function StructuredReportItem({ item, color, icon }: Props) {
export function StructuredReportItem({ item, color, icon, defaultActiveKey }: Props) {
const Icon = icon;
return (
<StyledCollapse color={color}>
<StyledCollapse color={color} defaultActiveKey={defaultActiveKey}>
<Collapse.Panel
header={
<Item>

View File

@ -16,9 +16,10 @@ interface Props {
color: string;
icon?: React.ComponentType<any>;
pageSize?: number;
defaultActiveKey?: string;
}
export function StructuredReportItemList({ items, color, icon, pageSize = 3 }: Props) {
export function StructuredReportItemList({ items, color, icon, pageSize = 3, defaultActiveKey }: Props) {
const [visibleCount, setVisibleCount] = useState(pageSize);
const visibleItems = items.slice(0, visibleCount);
const totalCount = items.length;
@ -31,6 +32,7 @@ export function StructuredReportItemList({ items, color, icon, pageSize = 3 }: P
item={item}
color={color}
icon={icon}
defaultActiveKey={defaultActiveKey}
key={`${item.message}-${item.context}`}
/>
))}

View File

@ -20,3 +20,9 @@ export interface ExecutionCancelInfo {
executionUrn: string;
sourceUrn: string;
}
export const enum TabType {
Summary = 'Summary',
Logs = 'Logs',
Recipe = 'Recipe',
}

View File

@ -1,59 +1,129 @@
import { Button, Typography } from 'antd';
import React, { useState } from 'react';
import { Button } from 'antd';
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
import { EmbeddedListSearchModal } from '@app/entity/shared/components/styled/search/EmbeddedListSearchModal';
import { ANTD_GRAY } from '@app/entity/shared/constants';
import {
extractEntityTypeCountsFromFacets,
getEntitiesIngestedByType,
getIngestionContents,
getOtherIngestionContents,
getTotalEntitiesIngested,
} from '@app/ingestV2/source/utils';
import { UnionType } from '@app/search/utils/constants';
import { Message } from '@app/shared/Message';
import { formatNumber } from '@app/shared/formatNumber';
import { capitalizeFirstLetterOnly } from '@app/shared/textUtil';
import { useEntityRegistry } from '@app/useEntityRegistry';
import { Heading, Pill, Text } from '@src/alchemy-components';
import colors from '@src/alchemy-components/theme/foundations/colors';
import { ExecutionRequestResult, Maybe } from '@src/types.generated';
import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
const HeaderContainer = styled.div`
// Base flex container with common spacing
const FlexContainer = styled.div`
display: flex;
justify-content: space-between;
gap: 16px;
`;
const TitleContainer = styled.div``;
const TotalContainer = styled.div`
// Base card styling
const BaseCard = styled.div`
display: flex;
padding: 12px;
background-color: white;
border: 1px solid ${colors.gray[1400]};
border-radius: 12px;
box-shadow: 0px 4px 8px 0px rgba(33, 23, 95, 0.04);
min-height: 60px;
`;
const MainContainer = styled(FlexContainer)`
align-items: stretch;
margin-top: 16px;
`;
const CardContainer = styled(BaseCard)`
flex-direction: column;
justify-content: right;
align-items: end;
justify-content: center;
align-items: flex-start;
flex: 1 0 0;
min-height: 80px;
max-height: 80px;
`;
const TotalText = styled(Typography.Text)`
font-size: 16px;
color: ${ANTD_GRAY[8]};
`;
const EntityCountsContainer = styled.div`
display: flex;
justify-content: left;
const TotalContainer = styled(BaseCard)`
flex-direction: row;
align-items: center;
max-width: 100%;
flex-wrap: wrap;
justify-content: space-between;
flex: 1 0 0;
min-height: 80px;
max-height: 80px;
`;
const EntityCount = styled.div`
margin-right: 40px;
const TotalInfo = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
`;
const ViewAllButton = styled(Button)`
padding: 0px;
margin-top: 4px;
const TypesSection = styled.div`
flex: 1;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
`;
const IngestionBoxesContainer = styled(FlexContainer)`
width: 100%;
`;
const EntityCountsContainer = styled(FlexContainer)`
flex: 1;
width: 100%;
align-items: stretch;
justify-content: flex-start;
flex-wrap: wrap;
`;
const TypesHeaderContainer = styled.div`
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 16px;
position: relative;
`;
const TypesHeader = styled(Text)`
position: absolute;
top: 0;
left: calc(50% + 33px);
margin-bottom: 0;
z-index: 1;
`;
const VerticalDivider = styled.div`
width: 2px;
background-color: ${colors.gray[1400]};
height: 80px;
align-self: center;
`;
const IngestionContentsContainer = styled.div`
margin-top: 20px;
`;
const IngestionBoxTopRow = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
width: 100%;
`;
const IngestionRowCount = styled(Text)`
margin-right: 10px;
`;
type Props = {
@ -64,6 +134,42 @@ type Props = {
const ENTITY_FACET_NAME = 'entity';
const TYPE_NAMES_FACET_NAME = 'typeNames';
type IngestionContentItem = {
title?: string;
type?: string;
count: number;
percent: string;
};
type RenderIngestionContentsProps = {
items: IngestionContentItem[];
getKey: (item: IngestionContentItem) => string;
getLabel: (item: IngestionContentItem) => string;
};
const IngestionContents: React.FC<RenderIngestionContentsProps> = ({ items, getKey, getLabel }) => (
<IngestionBoxesContainer>
{items.map((item) => (
<CardContainer key={getKey(item)}>
<IngestionBoxTopRow>
<IngestionRowCount size="xl" weight="bold" color="gray" colorLevel={800}>
{formatNumber(item.count)}
</IngestionRowCount>
<Pill
size="sm"
variant="filled"
color="gray"
label={item.count === 0 ? 'Missing' : `${item.percent} of Total`}
/>
</IngestionBoxTopRow>
<Text size="md" color="gray" colorLevel={600}>
{getLabel(item)}
</Text>
</CardContainer>
))}
</IngestionBoxesContainer>
);
export default function IngestedAssets({ id, executionResult }: Props) {
const entityRegistry = useEntityRegistry();
@ -113,44 +219,99 @@ export default function IngestedAssets({ id, executionResult }: Props) {
// The total number of assets ingested
const total = totalEntitiesIngested ?? data?.searchAcrossEntities?.total ?? 0;
const ingestionContents = useMemo(
() => executionResult && getIngestionContents(executionResult),
[executionResult],
);
const otherIngestionContents = useMemo(
() => executionResult && getOtherIngestionContents(executionResult),
[executionResult],
);
return (
<>
{error && <Message type="error" content="" />}
<HeaderContainer>
<TitleContainer>
<Typography.Title level={5}>Ingested Assets</Typography.Title>
{(loading && <Typography.Text type="secondary">Loading...</Typography.Text>) || (
<Heading type="h4" size="lg" weight="bold">
Assets
</Heading>
{loading && (
<Text color="gray" colorLevel={600}>
Loading...
</Text>
)}
{!loading && total === 0 && <Text>No assets were ingested.</Text>}
{!loading && total > 0 && (
<>
{(total > 0 && (
<Typography.Paragraph type="secondary">
The following asset types were ingested during this run.
</Typography.Paragraph>
)) || <Typography.Text>No assets were ingested.</Typography.Text>}
</>
)}
</TitleContainer>
{!loading && (
<TypesHeaderContainer>
<Text color="gray" colorLevel={600}>
Types and counts for this ingestion run.
</Text>
<TypesHeader size="sm" color="gray" colorLevel={600} weight="bold">
Types
</TypesHeader>
</TypesHeaderContainer>
<MainContainer>
<TotalContainer>
<Typography.Text type="secondary">Total</Typography.Text>
<TotalText style={{ fontSize: 16, color: ANTD_GRAY[8] }}>
<b>{formatNumber(total)}</b> assets
</TotalText>
<TotalInfo>
<Text size="xl" weight="bold" color="gray" colorLevel={800}>
{formatNumber(total)}
</Text>
<Text size="md" color="gray" colorLevel={600} style={{ marginTop: 2 }}>
Total Assets Ingested
</Text>
</TotalInfo>
<Button type="link" onClick={() => setShowAssetSearch(true)}>
View All
</Button>
</TotalContainer>
)}
</HeaderContainer>
<VerticalDivider />
<TypesSection>
<EntityCountsContainer>
{countsByEntityType.map((entityCount) => (
<EntityCount>
<Typography.Text style={{ paddingLeft: 2, fontSize: 18, color: ANTD_GRAY[8] }}>
<b>{formatNumber(entityCount.count)}</b>
</Typography.Text>
<Typography.Text type="secondary">{entityCount.displayName}</Typography.Text>
</EntityCount>
<CardContainer key={entityCount.displayName}>
<Text size="xl" weight="bold" color="gray" colorLevel={800}>
{formatNumber(entityCount.count)}
</Text>
<Text size="md" color="gray" colorLevel={600}>
{capitalizeFirstLetterOnly(entityCount.displayName)}
</Text>
</CardContainer>
))}
</EntityCountsContainer>
<ViewAllButton type="link" onClick={() => setShowAssetSearch(true)}>
View All
</ViewAllButton>
</TypesSection>
</MainContainer>
{ingestionContents && (
<IngestionContentsContainer>
<Heading type="h5" size="lg" weight="bold">
Coverage
</Heading>
<Text color="gray" colorLevel={600}>
Additional metadata collected during this ingestion run.
</Text>
<Text weight="semiBold" size="md">
Lineage
</Text>
<IngestionContents
items={ingestionContents}
getKey={(item) => item.title || ''}
getLabel={(item) => item.title || ''}
/>
{otherIngestionContents && (
<>
<Text weight="semiBold" size="md">
Statistics
</Text>
<IngestionContents
items={otherIngestionContents}
getKey={(item) => item.type || ''}
getLabel={(item) => item.type || ''}
/>
</>
)}
</IngestionContentsContainer>
)}
</>
)}
{showAssetSearch && (
<EmbeddedListSearchModal
title="View Ingested Assets"

View File

@ -15,6 +15,8 @@ import {
capitalizeMonthsAndDays,
formatTimezone,
getEntitiesIngestedByType,
getIngestionContents,
getOtherIngestionContents,
getSortInput,
getSourceStatus,
getTotalEntitiesIngested,
@ -412,6 +414,430 @@ describe('getSourceStatus', () => {
});
});
describe('getIngestionContents', () => {
test('returns null when structured report is not available', () => {
const result = getIngestionContents({} as Partial<ExecutionRequestResult>);
expect(result).toBeNull();
});
test('returns null when aspects_by_subtypes is empty', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toBeNull();
});
test('processes dataset subtypes with lineage information correctly', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
container: {
containerProperties: 156,
container: 117,
},
dataset: {
Table: {
status: 10,
upstreamLineage: 5,
datasetProfile: 10,
},
View: {
status: 20,
upstreamLineage: 10,
datasetProfile: 20,
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
title: 'Table',
count: 10,
percent: '50%',
},
{
title: 'View',
count: 20,
percent: '50%',
},
]);
});
test('filters out subtypes with 0% lineage', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 10,
upstreamLineage: 0,
},
View: {
status: 20,
upstreamLineage: 5,
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
title: 'View',
count: 20,
percent: '25%',
},
]);
});
test('filters out subtypes with status count of 0', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 0,
upstreamLineage: 5,
},
View: {
status: 20,
upstreamLineage: 10,
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
title: 'View',
count: 20,
percent: '50%',
},
]);
});
test('handles missing upstreamLineage property', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 10,
// upstreamLineage is missing
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toBeNull();
});
test('handles missing status property', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
// status is missing
upstreamLineage: 5,
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toBeNull();
});
test('calculates percentage correctly and rounds to nearest integer', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 7,
upstreamLineage: 2, // 2/7 = 28.57...% rounds to 29%
},
View: {
status: 3,
upstreamLineage: 1, // 1/3 = 33.33...% rounds to 33%
},
},
},
},
},
};
const result = getIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
title: 'Table',
count: 7,
percent: '29%',
},
{
title: 'View',
count: 3,
percent: '33%',
},
]);
});
});
describe('getOtherIngestionContents', () => {
test('returns null when structured report is not available', () => {
const result = getOtherIngestionContents({} as Partial<ExecutionRequestResult>);
expect(result).toBeNull();
});
test('returns null when aspects_by_subtypes is empty', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toBeNull();
});
test('processes multiple dataset subtypes and aggregates profiling and usage entries', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 10,
datasetProfile: 5,
datasetUsageStatistics: 3,
},
View: {
status: 20,
datasetProfile: 8,
datasetUsageStatistics: 12,
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
type: 'Profiling',
count: 13, // 5 + 8
percent: '43%', // (13 / 30) * 100 = 43.33...% rounds to 43%
},
{
type: 'Usage',
count: 15, // 3 + 12
percent: '50%', // (15 / 30) * 100 = 50%
},
]);
});
test('filters out subtypes with zero profiling and usage counts', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 10,
datasetProfile: 0,
datasetUsageStatistics: 0,
},
View: {
status: 20,
datasetProfile: 8,
datasetUsageStatistics: 12,
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
type: 'Profiling',
count: 8,
percent: '27%', // (8 / 30) * 100 = 26.66...% rounds to 27%
},
{
type: 'Usage',
count: 12,
percent: '40%', // (12 / 30) * 100 = 40%
},
]);
});
test('filters out subtypes with zero status count', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 0,
datasetProfile: 5,
datasetUsageStatistics: 3,
},
View: {
status: 20,
datasetProfile: 8,
datasetUsageStatistics: 12,
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
type: 'Profiling',
count: 8,
percent: '40%', // (8 / 20) * 100 = 40%
},
{
type: 'Usage',
count: 12,
percent: '60%', // (12 / 20) * 100 = 60%
},
]);
});
test('handles missing datasetProfile and datasetUsageStatistics properties', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 10,
// datasetProfile and datasetUsageStatistics are missing
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
count: 0,
percent: '0%',
type: 'Usage',
},
]);
});
test('ignores non-dataset entity types', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
container: {
Container: {
status: 10,
datasetProfile: 5,
datasetUsageStatistics: 3,
},
},
dataset: {
Table: {
status: 20,
datasetProfile: 8,
datasetUsageStatistics: 12,
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
type: 'Profiling',
count: 8,
percent: '40%', // (8 / 20) * 100 = 40%
},
{
type: 'Usage',
count: 12,
percent: '60%', // (12 / 20) * 100 = 60%
},
]);
});
test('calculates percentage correctly and rounds to nearest integer', () => {
const structuredReport = {
source: {
report: {
aspects_by_subtypes: {
dataset: {
Table: {
status: 7,
datasetProfile: 2, // 2/7 = 28.57...% rounds to 29%
datasetUsageStatistics: 1, // 1/7 = 14.28...% rounds to 14%
},
},
},
},
},
};
const result = getOtherIngestionContents(mockExecutionRequestResult(structuredReport));
expect(result).toEqual([
{
type: 'Profiling',
count: 2,
percent: '29%',
},
{
type: 'Usage',
count: 1,
percent: '14%',
},
]);
});
});
describe('buildOwnerEntities', () => {
const entityUrn = 'urn:li:entity:123';
const ownerUrn = 'urn:li:user:123';

View File

@ -303,6 +303,118 @@ export const getTotalEntitiesIngested = (result: Partial<ExecutionRequestResult>
return entityTypeCounts.reduce((total, entityType) => total + entityType.count, 0);
};
export const getOtherIngestionContents = (executionResult: Partial<ExecutionRequestResult>) => {
const structuredReportObject = extractStructuredReportPOJO(executionResult);
if (!structuredReportObject) {
return null;
}
const aspectsBySubtypes = structuredReportObject?.source?.report?.aspects_by_subtypes;
if (!aspectsBySubtypes || Object.keys(aspectsBySubtypes).length === 0) {
return null;
}
let totalStatusCount = 0;
let totalDatasetProfileCount = 0;
let totalDatasetUsageStatisticsCount = 0;
Object.entries(aspectsBySubtypes).forEach(([entityType, subtypes]) => {
if (entityType !== 'dataset') {
// temporary for now - we have not decided on the design for non dataset entity types
return;
}
Object.entries(subtypes as Record<string, any>).forEach(([_, aspects]) => {
const statusCount = (aspects as any)?.status || 0;
if (statusCount === 0) {
return;
}
const dataSetProfileCount = (aspects as any)?.datasetProfile || 0;
const dataSetUsageStatisticsCount = (aspects as any)?.datasetUsageStatistics || 0;
totalStatusCount += statusCount;
totalDatasetProfileCount += dataSetProfileCount;
totalDatasetUsageStatisticsCount += dataSetUsageStatisticsCount;
});
});
if (totalStatusCount === 0) {
return null;
}
const result: Array<{ type: string; count: number; percent: string }> = [];
if (totalDatasetProfileCount > 0) {
const datasetProfilePercent = `${((totalDatasetProfileCount / totalStatusCount) * 100).toFixed(0)}%`;
result.push({
type: 'Profiling',
count: totalDatasetProfileCount,
percent: datasetProfilePercent,
});
}
if (totalDatasetUsageStatisticsCount > 0) {
const datasetUsageStatisticsPercent = `${((totalDatasetUsageStatisticsCount / totalStatusCount) * 100).toFixed(0)}%`;
result.push({
type: 'Usage',
count: totalDatasetUsageStatisticsCount,
percent: datasetUsageStatisticsPercent,
});
} else {
result.push({
type: 'Usage',
count: 0,
percent: '0%',
});
}
if (result.length === 0) {
return null;
}
return result;
};
export const getIngestionContents = (executionResult: Partial<ExecutionRequestResult>) => {
const structuredReportObject = extractStructuredReportPOJO(executionResult);
if (!structuredReportObject) {
return null;
}
const aspectsBySubtypes = structuredReportObject.source.report.aspects_by_subtypes;
if (!aspectsBySubtypes || Object.keys(aspectsBySubtypes).length === 0) {
return null;
}
const result: Array<{ title: string; count: number; percent: string }> = [];
Object.entries(aspectsBySubtypes).forEach(([entityType, subtypes]) => {
if (entityType !== 'dataset') {
// temporary for now - we have not decided on the design for non dataset entity types
return;
}
Object.entries(subtypes as Record<string, any>).forEach(([subtype, aspects]) => {
const statusCount = (aspects as any)?.status || 0;
const upstreamLineage = (aspects as any)?.upstreamLineage || 0;
if (statusCount === 0) {
return;
}
const percent = `${((upstreamLineage / statusCount) * 100).toFixed(0)}%`;
if (percent === '0%') {
return;
}
result.push({
title: subtype,
count: statusCount,
percent,
});
});
});
if (result.length === 0) {
return null;
}
return result;
};
export const getIngestionSourceStatus = (result?: Partial<ExecutionRequestResult> | null) => {
if (!result) {
return undefined;