mirror of
https://github.com/apache/superset.git
synced 2026-06-27 10:29:21 +00:00
Compare commits
6 Commits
chore/ci/s
...
adopt/line
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85f4d8890 | ||
|
|
f0a247d7c7 | ||
|
|
36d154b363 | ||
|
|
17033882d1 | ||
|
|
22db868f81 | ||
|
|
80e0412ebf |
@@ -89,6 +89,8 @@ import {
|
|||||||
import { DatabaseSelector } from '../../../DatabaseSelector';
|
import { DatabaseSelector } from '../../../DatabaseSelector';
|
||||||
import CollectionTable from '../CollectionTable';
|
import CollectionTable from '../CollectionTable';
|
||||||
import Fieldset from '../Fieldset';
|
import Fieldset from '../Fieldset';
|
||||||
|
import { useDatasetLineage } from 'src/hooks/apiResources';
|
||||||
|
import { LineageView } from 'src/features/lineage';
|
||||||
import Field from '../Field';
|
import Field from '../Field';
|
||||||
import { fetchSyncedColumns, updateColumns } from '../../utils';
|
import { fetchSyncedColumns, updateColumns } from '../../utils';
|
||||||
import DatasetUsageTab from './components/DatasetUsageTab';
|
import DatasetUsageTab from './components/DatasetUsageTab';
|
||||||
@@ -425,6 +427,16 @@ const StyledTableTabWrapper = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Functional wrapper for the lineage tab, since hooks can't be used directly in
|
||||||
|
// the DatasourceEditor class component.
|
||||||
|
function DatasetLineageTab({ datasourceId }: { datasourceId?: number }) {
|
||||||
|
const lineageResource = useDatasetLineage(datasourceId ?? '');
|
||||||
|
if (!datasourceId) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
return <LineageView lineageResource={lineageResource} entityType="dataset" />;
|
||||||
|
}
|
||||||
|
|
||||||
const DefaultColumnSettingsContainer = styled.div`
|
const DefaultColumnSettingsContainer = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
margin-bottom: ${theme.sizeUnit * 4}px;
|
margin-bottom: ${theme.sizeUnit * 4}px;
|
||||||
@@ -477,6 +489,7 @@ const TABS_KEYS = {
|
|||||||
COLUMNS: 'COLUMNS',
|
COLUMNS: 'COLUMNS',
|
||||||
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
|
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
|
||||||
USAGE: 'USAGE',
|
USAGE: 'USAGE',
|
||||||
|
LINEAGE: 'LINEAGE',
|
||||||
FOLDERS: 'FOLDERS',
|
FOLDERS: 'FOLDERS',
|
||||||
SETTINGS: 'SETTINGS',
|
SETTINGS: 'SETTINGS',
|
||||||
SPATIAL: 'SPATIAL',
|
SPATIAL: 'SPATIAL',
|
||||||
@@ -2515,6 +2528,15 @@ class DatasourceEditor extends PureComponent<
|
|||||||
</StyledTableTabWrapper>
|
</StyledTableTabWrapper>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: TABS_KEYS.LINEAGE,
|
||||||
|
label: t('Lineage'),
|
||||||
|
children: (
|
||||||
|
<StyledTableTabWrapper>
|
||||||
|
<DatasetLineageTab datasourceId={datasource.id} />
|
||||||
|
</StyledTableTabWrapper>
|
||||||
|
),
|
||||||
|
},
|
||||||
...(isFeatureEnabled(FeatureFlag.DatasetFolders)
|
...(isFeatureEnabled(FeatureFlag.DatasetFolders)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
|||||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||||
import { usePermissions } from 'src/hooks/usePermissions';
|
import { usePermissions } from 'src/hooks/usePermissions';
|
||||||
|
import { LineageModal } from 'src/features/lineage';
|
||||||
|
|
||||||
export const useHeaderActionsMenu = ({
|
export const useHeaderActionsMenu = ({
|
||||||
customCss,
|
customCss,
|
||||||
@@ -234,6 +235,23 @@ export const useHeaderActionsMenu = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// View lineage (available in both view and edit mode; lineage is
|
||||||
|
// read-only information about the dashboard's upstream assets)
|
||||||
|
if (dashboardId) {
|
||||||
|
menuItems.push(
|
||||||
|
createModalMenuItem(
|
||||||
|
MenuKeys.ViewLineage,
|
||||||
|
<LineageModal
|
||||||
|
entityType="dashboard"
|
||||||
|
entityId={dashboardId}
|
||||||
|
triggerNode={
|
||||||
|
<div data-test="view-lineage-menu-item">{t('View lineage')}</div>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Edit properties
|
// Edit properties
|
||||||
if (editMode) {
|
if (editMode) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|||||||
@@ -393,4 +393,5 @@ export enum MenuKeys {
|
|||||||
ManageEmailReports = 'manage_email_reports',
|
ManageEmailReports = 'manage_email_reports',
|
||||||
ExportPivotXlsx = 'export_pivot_xlsx',
|
ExportPivotXlsx = 'export_pivot_xlsx',
|
||||||
EmbedCode = 'embed_code',
|
EmbedCode = 'embed_code',
|
||||||
|
ViewLineage = 'view_lineage',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import { ReportObject } from 'src/features/reports/types';
|
|||||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||||
import EmbedCodeContent from '../EmbedCodeContent';
|
import EmbedCodeContent from '../EmbedCodeContent';
|
||||||
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
||||||
|
import { LineageModal } from 'src/features/lineage';
|
||||||
|
|
||||||
export const SEARCH_THRESHOLD = 10;
|
export const SEARCH_THRESHOLD = 10;
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ const MENU_KEYS = {
|
|||||||
EDIT_REPORT: 'edit_report',
|
EDIT_REPORT: 'edit_report',
|
||||||
DELETE_REPORT: 'delete_report',
|
DELETE_REPORT: 'delete_report',
|
||||||
VIEW_QUERY: 'view_query',
|
VIEW_QUERY: 'view_query',
|
||||||
|
VIEW_LINEAGE: 'view_lineage',
|
||||||
RUN_IN_SQL_LAB: 'run_in_sql_lab',
|
RUN_IN_SQL_LAB: 'run_in_sql_lab',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1028,6 +1030,23 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
onClick: () => setIsDropdownVisible(false),
|
onClick: () => setIsDropdownVisible(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// View lineage
|
||||||
|
if (slice?.slice_id) {
|
||||||
|
menuItems.push({
|
||||||
|
key: MENU_KEYS.VIEW_LINEAGE,
|
||||||
|
label: (
|
||||||
|
<LineageModal
|
||||||
|
entityType="chart"
|
||||||
|
entityId={slice.slice_id}
|
||||||
|
triggerNode={
|
||||||
|
<div data-test="view-lineage-menu-item">{t('View lineage')}</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onClick: () => setIsDropdownVisible(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Run in SQL Lab
|
// Run in SQL Lab
|
||||||
if (datasource) {
|
if (datasource) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
|
|||||||
import { assetUrl } from 'src/utils/assetUrl';
|
import { assetUrl } from 'src/utils/assetUrl';
|
||||||
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
|
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
|
||||||
import { TableTab } from 'src/views/CRUD/types';
|
import { TableTab } from 'src/views/CRUD/types';
|
||||||
|
import { LineageModal } from 'src/features/lineage';
|
||||||
|
|
||||||
interface ChartCardProps {
|
interface ChartCardProps {
|
||||||
chart: Chart;
|
chart: Chart;
|
||||||
@@ -76,6 +77,7 @@ export default function ChartCard({
|
|||||||
const canEdit = hasPerm('can_write');
|
const canEdit = hasPerm('can_write');
|
||||||
const canDelete = hasPerm('can_write');
|
const canDelete = hasPerm('can_write');
|
||||||
const canExport = hasPerm('can_export');
|
const canExport = hasPerm('can_export');
|
||||||
|
const canRead = hasPerm('can_read');
|
||||||
const menuItems: MenuItem[] = [];
|
const menuItems: MenuItem[] = [];
|
||||||
|
|
||||||
if (canEdit) {
|
if (canEdit) {
|
||||||
@@ -100,6 +102,29 @@ export default function ChartCard({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
menuItems.push({
|
||||||
|
key: 'lineage',
|
||||||
|
label: (
|
||||||
|
<LineageModal
|
||||||
|
entityType="chart"
|
||||||
|
entityId={chart.id}
|
||||||
|
triggerNode={
|
||||||
|
<div>
|
||||||
|
<Icons.ShareAltOutlined
|
||||||
|
iconSize="l"
|
||||||
|
css={css`
|
||||||
|
vertical-align: text-top;
|
||||||
|
`}
|
||||||
|
/>{' '}
|
||||||
|
{t('View Lineage')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (canExport) {
|
if (canExport) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'export',
|
key: 'export',
|
||||||
@@ -167,54 +192,59 @@ export default function ChartCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardStyles
|
<>
|
||||||
onClick={() => {
|
<CardStyles
|
||||||
if (!bulkSelectEnabled && chart.url) {
|
onClick={() => {
|
||||||
history.push(chart.url);
|
if (!bulkSelectEnabled && chart.url) {
|
||||||
}
|
history.push(chart.url);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<ListViewCard
|
>
|
||||||
loading={loading}
|
<ListViewCard
|
||||||
title={chart.slice_name}
|
loading={loading}
|
||||||
certifiedBy={chart.certified_by}
|
title={chart.slice_name}
|
||||||
certificationDetails={chart.certification_details}
|
certifiedBy={chart.certified_by}
|
||||||
cover={
|
certificationDetails={chart.certification_details}
|
||||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
cover={
|
||||||
<></>
|
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||||
) : null
|
<></>
|
||||||
}
|
) : null
|
||||||
url={bulkSelectEnabled ? undefined : chart.url}
|
}
|
||||||
imgURL={chart.thumbnail_url || ''}
|
url={bulkSelectEnabled ? undefined : chart.url}
|
||||||
imgFallbackURL={assetUrl(
|
imgURL={chart.thumbnail_url || ''}
|
||||||
'/static/assets/images/chart-card-fallback.svg',
|
imgFallbackURL={assetUrl(
|
||||||
)}
|
'/static/assets/images/chart-card-fallback.svg',
|
||||||
description={t('Modified %s', chart.changed_on_delta_humanized)}
|
)}
|
||||||
coverLeft={<FacePile users={chart.owners || []} />}
|
description={t('Modified %s', chart.changed_on_delta_humanized)}
|
||||||
coverRight={<Label>{chart.datasource_name_text}</Label>}
|
coverLeft={<FacePile users={chart.owners || []} />}
|
||||||
linkComponent={Link}
|
coverRight={<Label>{chart.datasource_name_text}</Label>}
|
||||||
actions={
|
linkComponent={Link}
|
||||||
<ListViewCard.Actions
|
actions={
|
||||||
onClick={e => {
|
<ListViewCard.Actions
|
||||||
e.stopPropagation();
|
onClick={e => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
}}
|
||||||
{userId && (
|
>
|
||||||
<FaveStar
|
{userId && (
|
||||||
itemId={chart.id}
|
<FaveStar
|
||||||
saveFaveStar={saveFavoriteStatus}
|
itemId={chart.id}
|
||||||
isStarred={favoriteStatus}
|
saveFaveStar={saveFavoriteStatus}
|
||||||
/>
|
isStarred={favoriteStatus}
|
||||||
)}
|
/>
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
|
)}
|
||||||
<Button buttonSize="xsmall" type="link" buttonStyle="link">
|
<Dropdown
|
||||||
<Icons.MoreOutlined iconSize="xl" />
|
menu={{ items: menuItems }}
|
||||||
</Button>
|
trigger={['click', 'hover']}
|
||||||
</Dropdown>
|
>
|
||||||
</ListViewCard.Actions>
|
<Button buttonSize="xsmall" type="link" buttonStyle="link">
|
||||||
}
|
<Icons.MoreOutlined iconSize="xl" />
|
||||||
/>
|
</Button>
|
||||||
</CardStyles>
|
</Dropdown>
|
||||||
|
</ListViewCard.Actions>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardStyles>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
|
|||||||
import { Dashboard } from 'src/views/CRUD/types';
|
import { Dashboard } from 'src/views/CRUD/types';
|
||||||
import { assetUrl } from 'src/utils/assetUrl';
|
import { assetUrl } from 'src/utils/assetUrl';
|
||||||
import { FacePile } from 'src/components';
|
import { FacePile } from 'src/components';
|
||||||
|
import { LineageModal } from 'src/features/lineage';
|
||||||
|
|
||||||
interface DashboardCardProps {
|
interface DashboardCardProps {
|
||||||
isChart?: boolean;
|
isChart?: boolean;
|
||||||
@@ -64,6 +65,7 @@ function DashboardCard({
|
|||||||
const canEdit = hasPerm('can_write');
|
const canEdit = hasPerm('can_write');
|
||||||
const canDelete = hasPerm('can_write');
|
const canDelete = hasPerm('can_write');
|
||||||
const canExport = hasPerm('can_export');
|
const canExport = hasPerm('can_export');
|
||||||
|
const canRead = hasPerm('can_read');
|
||||||
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
const digest = dashboard.changed_on_utc || dashboard.changed_on;
|
||||||
const thumbnailUrl =
|
const thumbnailUrl =
|
||||||
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
|
||||||
@@ -72,6 +74,23 @@ function DashboardCard({
|
|||||||
|
|
||||||
const menuItems: MenuItem[] = [];
|
const menuItems: MenuItem[] = [];
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
menuItems.push({
|
||||||
|
key: 'lineage',
|
||||||
|
label: (
|
||||||
|
<LineageModal
|
||||||
|
entityType="dashboard"
|
||||||
|
entityId={dashboard.id}
|
||||||
|
triggerNode={
|
||||||
|
<div data-test="dashboard-card-option-lineage-button">
|
||||||
|
<Icons.ShareAltOutlined iconSize="l" /> {t('View Lineage')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (canEdit && openDashboardEditModal) {
|
if (canEdit && openDashboardEditModal) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'edit',
|
key: 'edit',
|
||||||
@@ -124,55 +143,60 @@ function DashboardCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardStyles
|
<>
|
||||||
onClick={() => {
|
<CardStyles
|
||||||
if (!bulkSelectEnabled) {
|
onClick={() => {
|
||||||
history.push(dashboard.url);
|
if (!bulkSelectEnabled) {
|
||||||
}
|
history.push(dashboard.url);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<ListViewCard
|
>
|
||||||
loading={dashboard.loading || false}
|
<ListViewCard
|
||||||
title={dashboard.dashboard_title}
|
loading={dashboard.loading || false}
|
||||||
certifiedBy={dashboard.certified_by}
|
title={dashboard.dashboard_title}
|
||||||
certificationDetails={dashboard.certification_details}
|
certifiedBy={dashboard.certified_by}
|
||||||
titleRight={<PublishedLabel isPublished={dashboard.published} />}
|
certificationDetails={dashboard.certification_details}
|
||||||
cover={
|
titleRight={<PublishedLabel isPublished={dashboard.published} />}
|
||||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
cover={
|
||||||
<></>
|
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||||
) : null
|
<></>
|
||||||
}
|
) : null
|
||||||
url={bulkSelectEnabled ? undefined : dashboard.url}
|
}
|
||||||
linkComponent={Link}
|
url={bulkSelectEnabled ? undefined : dashboard.url}
|
||||||
imgURL={thumbnailUrl}
|
linkComponent={Link}
|
||||||
imgFallbackURL={assetUrl(
|
imgURL={thumbnailUrl}
|
||||||
'/static/assets/images/dashboard-card-fallback.svg',
|
imgFallbackURL={assetUrl(
|
||||||
)}
|
'/static/assets/images/dashboard-card-fallback.svg',
|
||||||
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
|
)}
|
||||||
coverLeft={<FacePile users={dashboard.owners || []} />}
|
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
|
||||||
actions={
|
coverLeft={<FacePile users={dashboard.owners || []} />}
|
||||||
<ListViewCard.Actions
|
actions={
|
||||||
onClick={e => {
|
<ListViewCard.Actions
|
||||||
e.stopPropagation();
|
onClick={e => {
|
||||||
e.preventDefault();
|
e.stopPropagation();
|
||||||
}}
|
e.preventDefault();
|
||||||
>
|
}}
|
||||||
{userId && (
|
>
|
||||||
<FaveStar
|
{userId && (
|
||||||
itemId={dashboard.id}
|
<FaveStar
|
||||||
saveFaveStar={saveFavoriteStatus}
|
itemId={dashboard.id}
|
||||||
isStarred={favoriteStatus}
|
saveFaveStar={saveFavoriteStatus}
|
||||||
/>
|
isStarred={favoriteStatus}
|
||||||
)}
|
/>
|
||||||
<Dropdown menu={{ items: menuItems }} trigger={['hover', 'click']}>
|
)}
|
||||||
<Button buttonSize="xsmall" buttonStyle="link">
|
<Dropdown
|
||||||
<Icons.MoreOutlined iconSize="xl" />
|
menu={{ items: menuItems }}
|
||||||
</Button>
|
trigger={['hover', 'click']}
|
||||||
</Dropdown>
|
>
|
||||||
</ListViewCard.Actions>
|
<Button buttonSize="xsmall" buttonStyle="link">
|
||||||
}
|
<Icons.MoreOutlined iconSize="xl" />
|
||||||
/>
|
</Button>
|
||||||
</CardStyles>
|
</Dropdown>
|
||||||
|
</ListViewCard.Actions>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</CardStyles>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,14 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
import { t } from '@apache-superset/core/translation';
|
import { t } from '@apache-superset/core/translation';
|
||||||
import { styled } from '@apache-superset/core/theme';
|
import { styled } from '@apache-superset/core/theme';
|
||||||
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
|
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
|
||||||
import { Badge } from '@superset-ui/core/components';
|
import { Badge } from '@superset-ui/core/components';
|
||||||
import Tabs from '@superset-ui/core/components/Tabs';
|
import Tabs from '@superset-ui/core/components/Tabs';
|
||||||
|
import { useDatasetLineage } from 'src/hooks/apiResources';
|
||||||
|
import { LineageView } from 'src/features/lineage';
|
||||||
|
|
||||||
const StyledTabs = styled(Tabs)`
|
const StyledTabs = styled(Tabs)`
|
||||||
${({ theme }) => `
|
${({ theme }) => `
|
||||||
@@ -51,16 +54,25 @@ const TRANSLATIONS = {
|
|||||||
USAGE_TEXT: t('Usage'),
|
USAGE_TEXT: t('Usage'),
|
||||||
COLUMNS_TEXT: t('Columns'),
|
COLUMNS_TEXT: t('Columns'),
|
||||||
METRICS_TEXT: t('Metrics'),
|
METRICS_TEXT: t('Metrics'),
|
||||||
|
LINEAGE_TEXT: t('Lineage'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS_KEYS = {
|
const TABS_KEYS = {
|
||||||
COLUMNS: 'COLUMNS',
|
COLUMNS: 'COLUMNS',
|
||||||
METRICS: 'METRICS',
|
METRICS: 'METRICS',
|
||||||
USAGE: 'USAGE',
|
USAGE: 'USAGE',
|
||||||
|
LINEAGE: 'LINEAGE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditPage = ({ id }: EditPageProps) => {
|
const EditPage = ({ id }: EditPageProps) => {
|
||||||
const { usageCount } = useGetDatasetRelatedCounts(id);
|
const { usageCount } = useGetDatasetRelatedCounts(id);
|
||||||
|
const [activeKey, setActiveKey] = useState(TABS_KEYS.COLUMNS);
|
||||||
|
// Only fetch lineage once the user opens the Lineage tab to avoid
|
||||||
|
// unnecessary requests/backend load on page load.
|
||||||
|
const lineageResource = useDatasetLineage(
|
||||||
|
id,
|
||||||
|
activeKey !== TABS_KEYS.LINEAGE,
|
||||||
|
);
|
||||||
|
|
||||||
const usageTab = (
|
const usageTab = (
|
||||||
<TabStyles>
|
<TabStyles>
|
||||||
@@ -85,9 +97,23 @@ const EditPage = ({ id }: EditPageProps) => {
|
|||||||
label: usageTab,
|
label: usageTab,
|
||||||
children: null,
|
children: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: TABS_KEYS.LINEAGE,
|
||||||
|
label: TRANSLATIONS.LINEAGE_TEXT,
|
||||||
|
children: (
|
||||||
|
<LineageView lineageResource={lineageResource} entityType="dataset" />
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return <StyledTabs moreIcon={null} items={items} />;
|
return (
|
||||||
|
<StyledTabs
|
||||||
|
moreIcon={null}
|
||||||
|
items={items}
|
||||||
|
activeKey={activeKey}
|
||||||
|
onChange={setActiveKey}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default EditPage;
|
export default EditPage;
|
||||||
|
|||||||
79
superset-frontend/src/features/lineage/LineageModal.tsx
Normal file
79
superset-frontend/src/features/lineage/LineageModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { FC, ReactNode } from 'react';
|
||||||
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { ModalTrigger } from '@superset-ui/core/components';
|
||||||
|
import {
|
||||||
|
useChartLineage,
|
||||||
|
useDashboardLineage,
|
||||||
|
useDatasetLineage,
|
||||||
|
} from 'src/hooks/apiResources';
|
||||||
|
import LineageView from './LineageView';
|
||||||
|
|
||||||
|
export interface LineageModalProps {
|
||||||
|
entityType: 'dataset' | 'chart' | 'dashboard';
|
||||||
|
entityId: string | number;
|
||||||
|
triggerNode: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineageModal: FC<LineageModalProps> = ({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
triggerNode,
|
||||||
|
}) => {
|
||||||
|
const datasetLineage = useDatasetLineage(
|
||||||
|
entityType === 'dataset' ? entityId : '',
|
||||||
|
);
|
||||||
|
const chartLineage = useChartLineage(entityType === 'chart' ? entityId : '');
|
||||||
|
const dashboardLineage = useDashboardLineage(
|
||||||
|
entityType === 'dashboard' ? entityId : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const lineageResource =
|
||||||
|
entityType === 'dataset'
|
||||||
|
? datasetLineage
|
||||||
|
: entityType === 'chart'
|
||||||
|
? chartLineage
|
||||||
|
: dashboardLineage;
|
||||||
|
|
||||||
|
const title =
|
||||||
|
entityType === 'dataset'
|
||||||
|
? t('Dataset Lineage')
|
||||||
|
: entityType === 'chart'
|
||||||
|
? t('Chart Lineage')
|
||||||
|
: t('Dashboard Lineage');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalTrigger
|
||||||
|
triggerNode={triggerNode}
|
||||||
|
modalTitle={title}
|
||||||
|
modalBody={
|
||||||
|
<LineageView
|
||||||
|
lineageResource={lineageResource}
|
||||||
|
entityType={entityType}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
width="850px"
|
||||||
|
responsive
|
||||||
|
destroyOnHidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineageModal;
|
||||||
731
superset-frontend/src/features/lineage/LineageView.tsx
Normal file
731
superset-frontend/src/features/lineage/LineageView.tsx
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { FC, useMemo, useState, useCallback } from 'react';
|
||||||
|
import { t } from '@apache-superset/core/translation';
|
||||||
|
import { styled, useTheme } from '@apache-superset/core/theme';
|
||||||
|
import { Empty, Loading } from '@superset-ui/core/components';
|
||||||
|
import { Button } from '@superset-ui/core/components';
|
||||||
|
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||||
|
import type { Resource } from 'src/hooks/apiResources/apiResources';
|
||||||
|
import type {
|
||||||
|
DatasetLineage,
|
||||||
|
ChartLineage,
|
||||||
|
DashboardLineage,
|
||||||
|
ChartEntity,
|
||||||
|
DashboardEntity,
|
||||||
|
DatasetEntity,
|
||||||
|
DatabaseEntity,
|
||||||
|
} from 'src/hooks/apiResources/lineage';
|
||||||
|
import Echart from '../../../plugins/plugin-chart-echarts/src/components/Echart';
|
||||||
|
import type { EChartsCoreOption } from 'echarts/core';
|
||||||
|
|
||||||
|
const LineageContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Legend = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${theme.sizeUnit * 4}px;
|
||||||
|
padding: ${theme.sizeUnit * 3}px;
|
||||||
|
background-color: ${theme.colorBgLayout};
|
||||||
|
border-bottom: 1px solid ${theme.colorBorder};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const LegendItem = styled.div<{ color: string }>`
|
||||||
|
${({ theme, color }) => `
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: ${theme.sizeUnit * 2}px;
|
||||||
|
font-size: ${theme.fontSizeSM}px;
|
||||||
|
color: ${theme.colorText};
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: ${color};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailsPanel = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
padding: ${theme.sizeUnit * 4}px;
|
||||||
|
background-color: ${theme.colorBgLayout};
|
||||||
|
border-top: 1px solid ${theme.colorBorder};
|
||||||
|
min-height: 120px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailsPanelHeader = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: ${theme.sizeUnit * 3}px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailsPanelActions = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
display: flex;
|
||||||
|
gap: ${theme.sizeUnit * 2}px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailsPanelTitle = styled.h4`
|
||||||
|
${({ theme }) => `
|
||||||
|
margin: 0;
|
||||||
|
font-size: ${theme.fontSizeLG}px;
|
||||||
|
font-weight: ${theme.fontWeightStrong};
|
||||||
|
color: ${theme.colorText};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailsPanelContent = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: ${theme.sizeUnit * 2}px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailRow = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
display: flex;
|
||||||
|
gap: ${theme.sizeUnit * 2}px;
|
||||||
|
font-size: ${theme.fontSizeSM}px;
|
||||||
|
color: ${theme.colorText};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailLabel = styled.span`
|
||||||
|
${({ theme }) => `
|
||||||
|
font-weight: ${theme.fontWeightStrong};
|
||||||
|
min-width: 100px;
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DetailValue = styled.span`
|
||||||
|
${({ theme }) => `
|
||||||
|
color: ${theme.colorTextSecondary};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type NodeType = 'database' | 'dataset' | 'chart' | 'dashboard';
|
||||||
|
|
||||||
|
type NodeDetails = {
|
||||||
|
name: string;
|
||||||
|
type: NodeType;
|
||||||
|
id?: number;
|
||||||
|
additionalInfo?: Record<string, string | number | null | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a stable, unique graph identity for a node so that entities sharing the
|
||||||
|
// same display name (e.g. two charts with identical titles) never collapse into
|
||||||
|
// a single Sankey node. The human-readable name is kept separately as the label.
|
||||||
|
const nodeKey = (type: NodeType, id?: number, name?: string): string =>
|
||||||
|
id != null ? `${type}:${id}` : `${type}:${name ?? ''}`;
|
||||||
|
|
||||||
|
type LineageViewProps = {
|
||||||
|
lineageResource:
|
||||||
|
| Resource<DatasetLineage>
|
||||||
|
| Resource<ChartLineage>
|
||||||
|
| Resource<DashboardLineage>;
|
||||||
|
entityType: 'dataset' | 'chart' | 'dashboard';
|
||||||
|
};
|
||||||
|
|
||||||
|
const LineageView: FC<LineageViewProps> = ({ lineageResource, entityType }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [selectedNode, setSelectedNode] = useState<NodeDetails | null>(null);
|
||||||
|
|
||||||
|
// Create a mapping of node names to their details
|
||||||
|
const nodeDetailsMap = useMemo(() => {
|
||||||
|
if (
|
||||||
|
lineageResource.status !== ResourceStatus.Complete ||
|
||||||
|
!lineageResource.result
|
||||||
|
) {
|
||||||
|
return new Map<string, NodeDetails>();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = lineageResource.result;
|
||||||
|
const map = new Map<string, NodeDetails>();
|
||||||
|
|
||||||
|
if (entityType === 'dataset' && 'dataset' in data) {
|
||||||
|
const { dataset, upstream, downstream } = data as DatasetLineage;
|
||||||
|
|
||||||
|
// Add current dataset
|
||||||
|
map.set(nodeKey('dataset', dataset.id, dataset.name), {
|
||||||
|
name: dataset.name,
|
||||||
|
type: 'dataset',
|
||||||
|
id: dataset.id,
|
||||||
|
additionalInfo: {
|
||||||
|
schema: dataset.schema,
|
||||||
|
table_name: dataset.table_name,
|
||||||
|
database_name: dataset.database_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add upstream database
|
||||||
|
if (upstream?.database) {
|
||||||
|
map.set(
|
||||||
|
nodeKey(
|
||||||
|
'database',
|
||||||
|
upstream.database.id,
|
||||||
|
upstream.database.database_name,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
name: upstream.database.database_name,
|
||||||
|
type: 'database',
|
||||||
|
id: upstream.database.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream charts
|
||||||
|
if (downstream?.charts?.result) {
|
||||||
|
downstream.charts.result.forEach((chart: ChartEntity) => {
|
||||||
|
map.set(nodeKey('chart', chart.id, chart.slice_name), {
|
||||||
|
name: chart.slice_name,
|
||||||
|
type: 'chart',
|
||||||
|
id: chart.id,
|
||||||
|
additionalInfo: {
|
||||||
|
viz_type: chart.viz_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream dashboards
|
||||||
|
if (downstream?.dashboards?.result) {
|
||||||
|
downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
|
||||||
|
map.set(nodeKey('dashboard', dashboard.id, dashboard.title), {
|
||||||
|
name: dashboard.title,
|
||||||
|
type: 'dashboard',
|
||||||
|
id: dashboard.id,
|
||||||
|
additionalInfo: {
|
||||||
|
slug: dashboard.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (entityType === 'chart' && 'chart' in data) {
|
||||||
|
const { chart, upstream, downstream } = data as ChartLineage;
|
||||||
|
|
||||||
|
// Add current chart
|
||||||
|
map.set(nodeKey('chart', chart.id, chart.slice_name), {
|
||||||
|
name: chart.slice_name,
|
||||||
|
type: 'chart',
|
||||||
|
id: chart.id,
|
||||||
|
additionalInfo: {
|
||||||
|
viz_type: chart.viz_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add upstream dataset
|
||||||
|
if (upstream?.dataset) {
|
||||||
|
map.set(
|
||||||
|
nodeKey('dataset', upstream.dataset.id, upstream.dataset.name),
|
||||||
|
{
|
||||||
|
name: upstream.dataset.name,
|
||||||
|
type: 'dataset',
|
||||||
|
id: upstream.dataset.id,
|
||||||
|
additionalInfo: {
|
||||||
|
schema: upstream.dataset.schema,
|
||||||
|
table_name: upstream.dataset.table_name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add upstream database
|
||||||
|
if (upstream?.database) {
|
||||||
|
map.set(
|
||||||
|
nodeKey(
|
||||||
|
'database',
|
||||||
|
upstream.database.id,
|
||||||
|
upstream.database.database_name,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
name: upstream.database.database_name,
|
||||||
|
type: 'database',
|
||||||
|
id: upstream.database.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream dashboards
|
||||||
|
if (downstream?.dashboards?.result) {
|
||||||
|
downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
|
||||||
|
map.set(nodeKey('dashboard', dashboard.id, dashboard.title), {
|
||||||
|
name: dashboard.title,
|
||||||
|
type: 'dashboard',
|
||||||
|
id: dashboard.id,
|
||||||
|
additionalInfo: {
|
||||||
|
slug: dashboard.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (entityType === 'dashboard' && 'dashboard' in data) {
|
||||||
|
const { dashboard, upstream } = data as DashboardLineage;
|
||||||
|
|
||||||
|
// Add current dashboard
|
||||||
|
map.set(nodeKey('dashboard', dashboard.id, dashboard.title), {
|
||||||
|
name: dashboard.title,
|
||||||
|
type: 'dashboard',
|
||||||
|
id: dashboard.id,
|
||||||
|
additionalInfo: {
|
||||||
|
slug: dashboard.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add upstream charts
|
||||||
|
if (upstream?.charts?.result) {
|
||||||
|
upstream.charts.result.forEach((chart: ChartEntity) => {
|
||||||
|
map.set(nodeKey('chart', chart.id, chart.slice_name), {
|
||||||
|
name: chart.slice_name,
|
||||||
|
type: 'chart',
|
||||||
|
id: chart.id,
|
||||||
|
additionalInfo: {
|
||||||
|
viz_type: chart.viz_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add upstream datasets
|
||||||
|
if (upstream?.datasets?.result) {
|
||||||
|
upstream.datasets.result.forEach((dataset: DatasetEntity) => {
|
||||||
|
map.set(nodeKey('dataset', dataset.id, dataset.name), {
|
||||||
|
name: dataset.name,
|
||||||
|
type: 'dataset',
|
||||||
|
id: dataset.id,
|
||||||
|
additionalInfo: {
|
||||||
|
schema: dataset.schema,
|
||||||
|
table_name: dataset.table_name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add upstream databases
|
||||||
|
if (upstream?.databases?.result) {
|
||||||
|
upstream.databases.result.forEach((database: DatabaseEntity) => {
|
||||||
|
map.set(nodeKey('database', database.id, database.database_name), {
|
||||||
|
name: database.database_name,
|
||||||
|
type: 'database',
|
||||||
|
id: database.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [lineageResource, entityType]);
|
||||||
|
|
||||||
|
// Handle node click
|
||||||
|
const handleNodeClick = useCallback(
|
||||||
|
(params: {
|
||||||
|
dataType?: string;
|
||||||
|
name?: string;
|
||||||
|
event?: { stop: () => void };
|
||||||
|
}) => {
|
||||||
|
if (params.dataType === 'node' && params.name) {
|
||||||
|
const nodeDetails = nodeDetailsMap.get(params.name);
|
||||||
|
if (nodeDetails) {
|
||||||
|
setSelectedNode(nodeDetails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always stop event propagation to prevent tooltip issues
|
||||||
|
if (params.event) {
|
||||||
|
params.event.stop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[nodeDetailsMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
const echartOptions: EChartsCoreOption | null = useMemo(() => {
|
||||||
|
if (
|
||||||
|
lineageResource.status !== ResourceStatus.Complete ||
|
||||||
|
!lineageResource.result
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = lineageResource.result;
|
||||||
|
const nodes: {
|
||||||
|
name: string;
|
||||||
|
label?: { position?: string; formatter?: string };
|
||||||
|
itemStyle?: { color: string };
|
||||||
|
}[] = [];
|
||||||
|
const links: { source: string; target: string; value: number }[] = [];
|
||||||
|
const nodeSet = new Set<string>();
|
||||||
|
|
||||||
|
// Helper to add a node. `key` is the stable unique identity used for graph
|
||||||
|
// links and detail lookups; `label` is the human-readable text shown.
|
||||||
|
const addNode = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
labelPosition: 'left' | 'right' | 'inside',
|
||||||
|
) => {
|
||||||
|
if (!nodeSet.has(key)) {
|
||||||
|
nodeSet.add(key);
|
||||||
|
nodes.push({
|
||||||
|
name: key,
|
||||||
|
itemStyle: { color },
|
||||||
|
label: {
|
||||||
|
position: labelPosition,
|
||||||
|
formatter: label,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to add a link between two node keys
|
||||||
|
const addLink = (source: string, target: string) => {
|
||||||
|
links.push({ source, target, value: 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build nodes and links based on entity type
|
||||||
|
if (entityType === 'dataset' && 'dataset' in data) {
|
||||||
|
const { dataset, upstream, downstream } = data as DatasetLineage;
|
||||||
|
|
||||||
|
const datasetKey = nodeKey('dataset', dataset.id, dataset.name);
|
||||||
|
// Add current dataset node (center) - label inside
|
||||||
|
addNode(datasetKey, dataset.name, theme.colorPrimary, 'inside');
|
||||||
|
|
||||||
|
// Add upstream database - label on left
|
||||||
|
if (upstream?.database) {
|
||||||
|
const dbKey = nodeKey(
|
||||||
|
'database',
|
||||||
|
upstream.database.id,
|
||||||
|
upstream.database.database_name,
|
||||||
|
);
|
||||||
|
addNode(
|
||||||
|
dbKey,
|
||||||
|
upstream.database.database_name,
|
||||||
|
theme.colorInfo,
|
||||||
|
'left',
|
||||||
|
);
|
||||||
|
addLink(dbKey, datasetKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream charts - label on right
|
||||||
|
const chartKeys = new Map<number, string>();
|
||||||
|
if (downstream?.charts?.result) {
|
||||||
|
downstream.charts.result.forEach((chart: ChartEntity) => {
|
||||||
|
const chartKey = nodeKey('chart', chart.id, chart.slice_name);
|
||||||
|
chartKeys.set(chart.id, chartKey);
|
||||||
|
addNode(chartKey, chart.slice_name, theme.colorSuccess, 'right');
|
||||||
|
addLink(datasetKey, chartKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream dashboards - label on right
|
||||||
|
if (downstream?.dashboards?.result) {
|
||||||
|
downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
|
||||||
|
const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
|
||||||
|
addNode(dashKey, dashboard.title, theme.colorWarning, 'right');
|
||||||
|
|
||||||
|
// Link from charts to dashboards using chart_ids
|
||||||
|
if (dashboard.chart_ids && dashboard.chart_ids.length > 0) {
|
||||||
|
dashboard.chart_ids.forEach(chartId => {
|
||||||
|
const chartKey = chartKeys.get(chartId);
|
||||||
|
if (chartKey) {
|
||||||
|
addLink(chartKey, dashKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (entityType === 'chart' && 'chart' in data) {
|
||||||
|
const { chart, upstream, downstream } = data as ChartLineage;
|
||||||
|
|
||||||
|
const chartKey = nodeKey('chart', chart.id, chart.slice_name);
|
||||||
|
// Add current chart node (center) - label inside
|
||||||
|
addNode(chartKey, chart.slice_name, theme.colorPrimary, 'inside');
|
||||||
|
|
||||||
|
// Add upstream dataset - label on left
|
||||||
|
if (upstream?.dataset) {
|
||||||
|
const datasetKey = nodeKey(
|
||||||
|
'dataset',
|
||||||
|
upstream.dataset.id,
|
||||||
|
upstream.dataset.name,
|
||||||
|
);
|
||||||
|
addNode(datasetKey, upstream.dataset.name, theme.colorInfo, 'left');
|
||||||
|
addLink(datasetKey, chartKey);
|
||||||
|
|
||||||
|
// Add upstream database - label on left
|
||||||
|
if (upstream.database) {
|
||||||
|
const dbKey = nodeKey(
|
||||||
|
'database',
|
||||||
|
upstream.database.id,
|
||||||
|
upstream.database.database_name,
|
||||||
|
);
|
||||||
|
addNode(
|
||||||
|
dbKey,
|
||||||
|
upstream.database.database_name,
|
||||||
|
theme.colorWarning,
|
||||||
|
'left',
|
||||||
|
);
|
||||||
|
addLink(dbKey, datasetKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add downstream dashboards - label on right
|
||||||
|
if (downstream?.dashboards?.result) {
|
||||||
|
downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
|
||||||
|
const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
|
||||||
|
addNode(dashKey, dashboard.title, theme.colorSuccess, 'right');
|
||||||
|
addLink(chartKey, dashKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (entityType === 'dashboard' && 'dashboard' in data) {
|
||||||
|
const { dashboard, upstream } = data as DashboardLineage;
|
||||||
|
|
||||||
|
const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
|
||||||
|
// Add current dashboard node (right) - label inside
|
||||||
|
addNode(dashKey, dashboard.title, theme.colorPrimary, 'inside');
|
||||||
|
|
||||||
|
// Add upstream charts - label on left
|
||||||
|
const chartKeys = new Map<number, string>();
|
||||||
|
if (upstream?.charts?.result) {
|
||||||
|
upstream.charts.result.forEach((chart: ChartEntity) => {
|
||||||
|
const chartKey = nodeKey('chart', chart.id, chart.slice_name);
|
||||||
|
chartKeys.set(chart.id, chartKey);
|
||||||
|
addNode(chartKey, chart.slice_name, theme.colorInfo, 'left');
|
||||||
|
addLink(chartKey, dashKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add upstream datasets - label on left
|
||||||
|
const datasetKeys = new Map<number, string>();
|
||||||
|
if (upstream?.datasets?.result) {
|
||||||
|
upstream.datasets.result.forEach(dataset => {
|
||||||
|
const datasetKey = nodeKey('dataset', dataset.id, dataset.name);
|
||||||
|
datasetKeys.set(dataset.id, datasetKey);
|
||||||
|
addNode(datasetKey, dataset.name, theme.colorSuccess, 'left');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link charts to their specific datasets using dataset_id from each chart
|
||||||
|
if (upstream?.charts?.result) {
|
||||||
|
upstream.charts.result.forEach((chart: ChartEntity) => {
|
||||||
|
if (chart.dataset_id) {
|
||||||
|
const datasetKey = datasetKeys.get(chart.dataset_id);
|
||||||
|
const chartKey = chartKeys.get(chart.id);
|
||||||
|
if (datasetKey && chartKey) {
|
||||||
|
addLink(datasetKey, chartKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add upstream databases and link to their specific datasets
|
||||||
|
if (upstream?.databases?.result) {
|
||||||
|
upstream.databases.result.forEach(database => {
|
||||||
|
const dbKey = nodeKey(
|
||||||
|
'database',
|
||||||
|
database.id,
|
||||||
|
database.database_name,
|
||||||
|
);
|
||||||
|
addNode(dbKey, database.database_name, theme.colorWarning, 'left');
|
||||||
|
|
||||||
|
// Link databases to datasets that belong to them using database_id
|
||||||
|
if (upstream.datasets?.result) {
|
||||||
|
upstream.datasets.result.forEach(dataset => {
|
||||||
|
if (dataset.database_id === database.id) {
|
||||||
|
const datasetKey = datasetKeys.get(dataset.id);
|
||||||
|
if (datasetKey) {
|
||||||
|
addLink(dbKey, datasetKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: {
|
||||||
|
animation: false,
|
||||||
|
data: nodes,
|
||||||
|
lineStyle: {
|
||||||
|
color: 'source',
|
||||||
|
},
|
||||||
|
links,
|
||||||
|
type: 'sankey',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
show: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [lineageResource, entityType, theme]);
|
||||||
|
|
||||||
|
// Build legend data based on entity type
|
||||||
|
const legendItems: { label: string; color: string }[] = useMemo(() => {
|
||||||
|
if (entityType === 'dataset') {
|
||||||
|
return [
|
||||||
|
{ label: 'Database (Upstream)', color: theme.colorInfo },
|
||||||
|
{ label: 'Dataset (Current)', color: theme.colorPrimary },
|
||||||
|
{ label: 'Chart (Downstream)', color: theme.colorSuccess },
|
||||||
|
{ label: 'Dashboard (Downstream)', color: theme.colorWarning },
|
||||||
|
];
|
||||||
|
} else if (entityType === 'chart') {
|
||||||
|
return [
|
||||||
|
{ label: 'Database (Upstream)', color: theme.colorWarning },
|
||||||
|
{ label: 'Dataset (Upstream)', color: theme.colorInfo },
|
||||||
|
{ label: 'Chart (Current)', color: theme.colorPrimary },
|
||||||
|
{ label: 'Dashboard (Downstream)', color: theme.colorSuccess },
|
||||||
|
];
|
||||||
|
} else if (entityType === 'dashboard') {
|
||||||
|
return [
|
||||||
|
{ label: 'Database (Upstream)', color: theme.colorWarning },
|
||||||
|
{ label: 'Dataset (Upstream)', color: theme.colorSuccess },
|
||||||
|
{ label: 'Chart (Upstream)', color: theme.colorInfo },
|
||||||
|
{ label: 'Dashboard (Current)', color: theme.colorPrimary },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [entityType, theme]);
|
||||||
|
|
||||||
|
if (lineageResource.status === ResourceStatus.Loading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lineageResource.status === ResourceStatus.Error ||
|
||||||
|
!lineageResource.result
|
||||||
|
) {
|
||||||
|
return <Empty description={t('Failed to load lineage data')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!echartOptions) {
|
||||||
|
return <Empty description={t('No lineage data available')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get the URL for an entity
|
||||||
|
const getEntityUrl = (nodeDetails: NodeDetails): string => {
|
||||||
|
switch (nodeDetails.type) {
|
||||||
|
case 'dashboard':
|
||||||
|
return `/superset/dashboard/${nodeDetails.id}/`;
|
||||||
|
case 'chart':
|
||||||
|
return `/explore/?slice_id=${nodeDetails.id}`;
|
||||||
|
case 'dataset':
|
||||||
|
return `/dataset/${nodeDetails.id}`;
|
||||||
|
default:
|
||||||
|
return '#';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LineageContainer>
|
||||||
|
<Legend>
|
||||||
|
{legendItems.map(item => (
|
||||||
|
<LegendItem key={item.label} color={item.color}>
|
||||||
|
{item.label}
|
||||||
|
</LegendItem>
|
||||||
|
))}
|
||||||
|
</Legend>
|
||||||
|
<Echart
|
||||||
|
refs={{}}
|
||||||
|
height={selectedNode ? 450 : 600}
|
||||||
|
width={800}
|
||||||
|
echartOptions={echartOptions}
|
||||||
|
vizType="sankey"
|
||||||
|
eventHandlers={{
|
||||||
|
click: handleNodeClick,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedNode && (
|
||||||
|
<DetailsPanel>
|
||||||
|
<DetailsPanelHeader>
|
||||||
|
<DetailsPanelTitle>
|
||||||
|
{t(
|
||||||
|
'%s Details',
|
||||||
|
selectedNode.type.charAt(0).toUpperCase() +
|
||||||
|
selectedNode.type.slice(1),
|
||||||
|
)}
|
||||||
|
</DetailsPanelTitle>
|
||||||
|
<DetailsPanelActions>
|
||||||
|
{(selectedNode.type === 'dashboard' ||
|
||||||
|
selectedNode.type === 'chart') && (
|
||||||
|
<Button
|
||||||
|
buttonStyle="primary"
|
||||||
|
buttonSize="small"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = getEntityUrl(selectedNode);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Open')}{' '}
|
||||||
|
{selectedNode.type.charAt(0).toUpperCase() +
|
||||||
|
selectedNode.type.slice(1)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
buttonStyle="tertiary"
|
||||||
|
buttonSize="small"
|
||||||
|
onClick={() => setSelectedNode(null)}
|
||||||
|
>
|
||||||
|
{t('Close')}
|
||||||
|
</Button>
|
||||||
|
</DetailsPanelActions>
|
||||||
|
</DetailsPanelHeader>
|
||||||
|
<DetailsPanelContent>
|
||||||
|
<DetailRow>
|
||||||
|
<DetailLabel>{t('Name')}:</DetailLabel>
|
||||||
|
<DetailValue>{selectedNode.name}</DetailValue>
|
||||||
|
</DetailRow>
|
||||||
|
{selectedNode.id && (
|
||||||
|
<DetailRow>
|
||||||
|
<DetailLabel>{t('ID')}:</DetailLabel>
|
||||||
|
<DetailValue>{selectedNode.id}</DetailValue>
|
||||||
|
</DetailRow>
|
||||||
|
)}
|
||||||
|
{selectedNode.additionalInfo &&
|
||||||
|
Object.entries(selectedNode.additionalInfo).map(
|
||||||
|
([key, value]) => (
|
||||||
|
<DetailRow key={key}>
|
||||||
|
<DetailLabel>
|
||||||
|
{key.charAt(0).toUpperCase() +
|
||||||
|
key.slice(1).replace(/_/g, ' ')}
|
||||||
|
:
|
||||||
|
</DetailLabel>
|
||||||
|
<DetailValue>{String(value)}</DetailValue>
|
||||||
|
</DetailRow>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</DetailsPanelContent>
|
||||||
|
</DetailsPanel>
|
||||||
|
)}
|
||||||
|
</LineageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LineageView;
|
||||||
21
superset-frontend/src/features/lineage/index.ts
Normal file
21
superset-frontend/src/features/lineage/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as LineageView } from './LineageView';
|
||||||
|
export { default as LineageModal } from './LineageModal';
|
||||||
@@ -97,6 +97,48 @@ describe('apiResource hooks', () => {
|
|||||||
error: fakeError,
|
error: fakeError,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('skips the fetch and stays loading when skip is true', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue(fakeApiResult);
|
||||||
|
(makeApi as any).mockReturnValue(fetchMock);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useApiResourceFullBody('/test/endpoint', true),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.current).toEqual({
|
||||||
|
status: ResourceStatus.Loading,
|
||||||
|
result: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-enables the fetch when skip toggles from true to false', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue(fakeApiResult);
|
||||||
|
(makeApi as any).mockReturnValue(fetchMock);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ skip }) => useApiResourceFullBody('/test/endpoint', skip),
|
||||||
|
{ initialProps: { skip: true } },
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.status).toEqual(ResourceStatus.Loading);
|
||||||
|
|
||||||
|
rerender({ skip: false });
|
||||||
|
await act(async () => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current).toEqual({
|
||||||
|
status: ResourceStatus.Complete,
|
||||||
|
result: fakeApiResult,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const initialState: LoadingState = {
|
|||||||
*/
|
*/
|
||||||
export function useApiResourceFullBody<RESULT>(
|
export function useApiResourceFullBody<RESULT>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
skip = false,
|
||||||
): Resource<RESULT> {
|
): Resource<RESULT> {
|
||||||
const [resource, setResource] = useState<Resource<RESULT>>(initialState);
|
const [resource, setResource] = useState<Resource<RESULT>>(initialState);
|
||||||
const cancelRef = useRef<() => void>(() => {});
|
const cancelRef = useRef<() => void>(() => {});
|
||||||
@@ -98,6 +99,12 @@ export function useApiResourceFullBody<RESULT>(
|
|||||||
// when this effect runs, the endpoint has changed.
|
// when this effect runs, the endpoint has changed.
|
||||||
// cancel any current calls so that state doesn't get messed up.
|
// cancel any current calls so that state doesn't get messed up.
|
||||||
cancelRef.current();
|
cancelRef.current();
|
||||||
|
|
||||||
|
// Allow callers to opt out of fetching (e.g. when the identifier isn't
|
||||||
|
// known yet) so we don't fire requests against invalid endpoints.
|
||||||
|
if (skip) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
cancelRef.current = () => {
|
cancelRef.current = () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -132,7 +139,7 @@ export function useApiResourceFullBody<RESULT>(
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [endpoint]);
|
}, [endpoint, skip]);
|
||||||
|
|
||||||
return resource;
|
return resource;
|
||||||
}
|
}
|
||||||
@@ -181,9 +188,12 @@ const extractInnerResult = <T>(responseBody: { result: T }) =>
|
|||||||
*
|
*
|
||||||
* @param endpoint The url where the resource is located.
|
* @param endpoint The url where the resource is located.
|
||||||
*/
|
*/
|
||||||
export function useApiV1Resource<RESULT>(endpoint: string): Resource<RESULT> {
|
export function useApiV1Resource<RESULT>(
|
||||||
|
endpoint: string,
|
||||||
|
skip = false,
|
||||||
|
): Resource<RESULT> {
|
||||||
return useTransformedResource(
|
return useTransformedResource(
|
||||||
useApiResourceFullBody<{ result: RESULT }>(endpoint),
|
useApiResourceFullBody<{ result: RESULT }>(endpoint, skip),
|
||||||
extractInnerResult,
|
extractInnerResult,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export {
|
|||||||
export * from './catalogs';
|
export * from './catalogs';
|
||||||
export * from './charts';
|
export * from './charts';
|
||||||
export * from './dashboards';
|
export * from './dashboards';
|
||||||
|
export * from './lineage';
|
||||||
export * from './tables';
|
export * from './tables';
|
||||||
export * from './schemas';
|
export * from './schemas';
|
||||||
export * from './queryValidations';
|
export * from './queryValidations';
|
||||||
|
|||||||
128
superset-frontend/src/hooks/apiResources/lineage.test.ts
Normal file
128
superset-frontend/src/hooks/apiResources/lineage.test.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { makeApi } from '@superset-ui/core';
|
||||||
|
import {
|
||||||
|
useChartLineage,
|
||||||
|
useDashboardLineage,
|
||||||
|
useDatasetLineage,
|
||||||
|
} from './lineage';
|
||||||
|
|
||||||
|
jest.mock('@superset-ui/core', () => ({
|
||||||
|
...jest.requireActual('@superset-ui/core'),
|
||||||
|
makeApi: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedMakeApi = jest.mocked(makeApi);
|
||||||
|
|
||||||
|
// makeApi returns a function that issues the request; capture the endpoint it
|
||||||
|
// was configured with so we can assert the correct URL was built.
|
||||||
|
function mockApiSuccess(payload: unknown) {
|
||||||
|
const fetcher = jest.fn().mockResolvedValue({ result: payload });
|
||||||
|
mockedMakeApi.mockReturnValue(fetcher as any);
|
||||||
|
return fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApiError(error: Error) {
|
||||||
|
const fetcher = jest.fn().mockRejectedValue(error);
|
||||||
|
mockedMakeApi.mockReturnValue(fetcher as any);
|
||||||
|
return fetcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useDatasetLineage fetches dataset lineage and unwraps result', async () => {
|
||||||
|
const payload = {
|
||||||
|
dataset: { id: 1, name: 'ds' },
|
||||||
|
upstream: { database: { id: 2, database_name: 'db', backend: 'pg' } },
|
||||||
|
downstream: {
|
||||||
|
charts: { count: 0, result: [] },
|
||||||
|
dashboards: { count: 0, result: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockApiSuccess(payload);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDatasetLineage(1));
|
||||||
|
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('complete'));
|
||||||
|
|
||||||
|
expect(mockedMakeApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
endpoint: '/api/v1/dataset/1/lineage',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.result).toEqual(payload);
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useChartLineage builds the chart lineage endpoint', async () => {
|
||||||
|
mockApiSuccess({ chart: { id: 5, slice_name: 'c', viz_type: 'pie' } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChartLineage(5));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('complete'));
|
||||||
|
expect(mockedMakeApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ endpoint: '/api/v1/chart/5/lineage' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('useDashboardLineage builds the dashboard lineage endpoint', async () => {
|
||||||
|
mockApiSuccess({ dashboard: { id: 9, title: 'd', slug: 'd' } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDashboardLineage(9));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('complete'));
|
||||||
|
expect(mockedMakeApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ endpoint: '/api/v1/dashboard/9/lineage' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineage hooks surface network errors', async () => {
|
||||||
|
mockApiError(new Error('Network error'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDatasetLineage(1));
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.status).toBe('error'));
|
||||||
|
expect(result.current.result).toBeNull();
|
||||||
|
expect(result.current.error).toBeInstanceOf(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineage hooks skip the request when the id is empty', async () => {
|
||||||
|
const fetcher = mockApiSuccess({});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDatasetLineage(''));
|
||||||
|
|
||||||
|
// Empty id resolves immediately without ever firing a request, so we never
|
||||||
|
// hit an invalid endpoint such as `/api/v1/dataset//lineage`.
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lineage hooks skip the request when skip is true', async () => {
|
||||||
|
const fetcher = mockApiSuccess({});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useChartLineage(5, true));
|
||||||
|
|
||||||
|
expect(result.current.status).toBe('loading');
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
151
superset-frontend/src/hooks/apiResources/lineage.ts
Normal file
151
superset-frontend/src/hooks/apiResources/lineage.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useApiV1Resource } from './apiResources';
|
||||||
|
|
||||||
|
// Database entity type
|
||||||
|
export type DatabaseEntity = {
|
||||||
|
id: number;
|
||||||
|
database_name: string;
|
||||||
|
backend: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dataset entity type
|
||||||
|
export type DatasetEntity = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
schema: string | null;
|
||||||
|
table_name: string;
|
||||||
|
database_id: number;
|
||||||
|
database_name: string;
|
||||||
|
chart_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart entity type
|
||||||
|
export type ChartEntity = {
|
||||||
|
id: number;
|
||||||
|
slice_name: string;
|
||||||
|
viz_type: string;
|
||||||
|
dashboard_ids?: number[];
|
||||||
|
dataset_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard entity type
|
||||||
|
export type DashboardEntity = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
chart_ids?: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dataset lineage response type
|
||||||
|
export type DatasetLineage = {
|
||||||
|
dataset: DatasetEntity;
|
||||||
|
upstream: {
|
||||||
|
database: DatabaseEntity;
|
||||||
|
};
|
||||||
|
downstream: {
|
||||||
|
charts: {
|
||||||
|
count: number;
|
||||||
|
result: ChartEntity[];
|
||||||
|
};
|
||||||
|
dashboards: {
|
||||||
|
count: number;
|
||||||
|
result: DashboardEntity[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chart lineage response type
|
||||||
|
export type ChartLineage = {
|
||||||
|
chart: ChartEntity & {
|
||||||
|
datasource_id: number;
|
||||||
|
datasource_type: string;
|
||||||
|
};
|
||||||
|
upstream: {
|
||||||
|
dataset: DatasetEntity;
|
||||||
|
database: DatabaseEntity;
|
||||||
|
};
|
||||||
|
downstream: {
|
||||||
|
dashboards: {
|
||||||
|
count: number;
|
||||||
|
result: DashboardEntity[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard lineage response type
|
||||||
|
export type DashboardLineage = {
|
||||||
|
dashboard: DashboardEntity & {
|
||||||
|
published: boolean;
|
||||||
|
};
|
||||||
|
upstream: {
|
||||||
|
charts: {
|
||||||
|
count: number;
|
||||||
|
result: ChartEntity[];
|
||||||
|
};
|
||||||
|
datasets: {
|
||||||
|
count: number;
|
||||||
|
result: DatasetEntity[];
|
||||||
|
};
|
||||||
|
databases: {
|
||||||
|
count: number;
|
||||||
|
result: DatabaseEntity[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
downstream: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A missing/empty identifier means we have nothing to fetch yet; skip the
|
||||||
|
// request so we never hit invalid endpoints like `/api/v1/chart//lineage`.
|
||||||
|
const isEmptyId = (idOrUuid: string | number): boolean =>
|
||||||
|
idOrUuid === '' || idOrUuid == null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch lineage data for a dataset
|
||||||
|
* @param idOrUuid Dataset ID or UUID
|
||||||
|
* @param skip When true, defers the request (e.g. until the tab is active)
|
||||||
|
*/
|
||||||
|
export const useDatasetLineage = (idOrUuid: string | number, skip = false) =>
|
||||||
|
useApiV1Resource<DatasetLineage>(
|
||||||
|
`/api/v1/dataset/${idOrUuid}/lineage`,
|
||||||
|
skip || isEmptyId(idOrUuid),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch lineage data for a chart
|
||||||
|
* @param idOrUuid Chart ID or UUID
|
||||||
|
* @param skip When true, defers the request (e.g. until the tab is active)
|
||||||
|
*/
|
||||||
|
export const useChartLineage = (idOrUuid: string | number, skip = false) =>
|
||||||
|
useApiV1Resource<ChartLineage>(
|
||||||
|
`/api/v1/chart/${idOrUuid}/lineage`,
|
||||||
|
skip || isEmptyId(idOrUuid),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch lineage data for a dashboard
|
||||||
|
* @param idOrSlug Dashboard ID or slug
|
||||||
|
* @param skip When true, defers the request (e.g. until the tab is active)
|
||||||
|
*/
|
||||||
|
export const useDashboardLineage = (idOrSlug: string | number, skip = false) =>
|
||||||
|
useApiV1Resource<DashboardLineage>(
|
||||||
|
`/api/v1/dashboard/${idOrSlug}/lineage`,
|
||||||
|
skip || isEmptyId(idOrSlug),
|
||||||
|
);
|
||||||
@@ -132,6 +132,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
|||||||
"screenshot",
|
"screenshot",
|
||||||
"cache_screenshot",
|
"cache_screenshot",
|
||||||
"warm_up_cache",
|
"warm_up_cache",
|
||||||
|
"lineage",
|
||||||
}
|
}
|
||||||
class_permission_name = "Chart"
|
class_permission_name = "Chart"
|
||||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||||
@@ -316,6 +317,107 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
|||||||
except ChartNotFoundError:
|
except ChartNotFoundError:
|
||||||
return self.response_404()
|
return self.response_404()
|
||||||
|
|
||||||
|
@expose("/<id_or_uuid>/lineage", methods=("GET",))
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@statsd_metrics
|
||||||
|
@event_logger.log_this_with_context(
|
||||||
|
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
|
||||||
|
log_to_statsd=False,
|
||||||
|
)
|
||||||
|
def lineage(self, id_or_uuid: str) -> Response:
|
||||||
|
"""Get lineage information for a chart.
|
||||||
|
---
|
||||||
|
get:
|
||||||
|
summary: Get lineage information for a chart
|
||||||
|
description: >-
|
||||||
|
Returns upstream (dataset, database) and downstream (dashboards) lineage
|
||||||
|
information for a chart
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id_or_uuid
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Either the id of the chart, or its uuid
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Lineage information
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ChartLineageResponseSchema"
|
||||||
|
401:
|
||||||
|
$ref: '#/components/responses/401'
|
||||||
|
404:
|
||||||
|
$ref: '#/components/responses/404'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/500'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
chart = ChartDAO.get_by_id_or_uuid(id_or_uuid)
|
||||||
|
except ChartNotFoundError:
|
||||||
|
return self.response_404()
|
||||||
|
|
||||||
|
chart_info = {
|
||||||
|
"id": chart.id,
|
||||||
|
"slice_name": chart.slice_name,
|
||||||
|
"viz_type": chart.viz_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get upstream (dataset and database) information
|
||||||
|
upstream: dict[str, Any] = {}
|
||||||
|
if dataset := chart.datasource:
|
||||||
|
upstream["dataset"] = {
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"database_id": dataset.database_id,
|
||||||
|
"database_name": dataset.database.database_name
|
||||||
|
if dataset.database
|
||||||
|
else None,
|
||||||
|
"schema": dataset.schema,
|
||||||
|
"table_name": dataset.table_name,
|
||||||
|
}
|
||||||
|
if dataset.database:
|
||||||
|
upstream["database"] = {
|
||||||
|
"id": dataset.database.id,
|
||||||
|
"database_name": dataset.database.database_name,
|
||||||
|
"backend": dataset.database.backend,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
upstream["database"] = None
|
||||||
|
else:
|
||||||
|
upstream["dataset"] = None
|
||||||
|
upstream["database"] = None
|
||||||
|
|
||||||
|
# Get downstream (dashboards) information, filtered by the current
|
||||||
|
# user's permissions so lineage never exposes dashboards the user
|
||||||
|
# cannot access.
|
||||||
|
dashboards = []
|
||||||
|
for dashboard in chart.dashboards:
|
||||||
|
if not security_manager.can_access_dashboard(dashboard):
|
||||||
|
continue
|
||||||
|
dashboards.append(
|
||||||
|
{
|
||||||
|
"id": dashboard.id,
|
||||||
|
"title": dashboard.dashboard_title,
|
||||||
|
"slug": dashboard.slug,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
downstream = {
|
||||||
|
"dashboards": {
|
||||||
|
"count": len(dashboards),
|
||||||
|
"result": dashboards,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"chart": chart_info,
|
||||||
|
"upstream": upstream,
|
||||||
|
"downstream": downstream,
|
||||||
|
}
|
||||||
|
return self.response(200, result=result)
|
||||||
|
|
||||||
@expose("/", methods=("POST",))
|
@expose("/", methods=("POST",))
|
||||||
@protect()
|
@protect()
|
||||||
@safe
|
@safe
|
||||||
|
|||||||
@@ -1824,6 +1824,53 @@ class ChartGetResponseSchema(Schema):
|
|||||||
datasource_uuid = fields.UUID(attribute="table.uuid")
|
datasource_uuid = fields.UUID(attribute="table.uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageChartSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
slice_name = fields.String()
|
||||||
|
viz_type = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageDatasetSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
name = fields.String()
|
||||||
|
database_id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
schema = fields.String(allow_none=True)
|
||||||
|
table_name = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageDatabaseSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
backend = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageDashboardSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
title = fields.String()
|
||||||
|
slug = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageUpstreamSchema(Schema):
|
||||||
|
dataset = fields.Nested(ChartLineageDatasetSchema, allow_none=True)
|
||||||
|
database = fields.Nested(ChartLineageDatabaseSchema, allow_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageDownstreamDashboardsSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(ChartLineageDashboardSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageDownstreamSchema(Schema):
|
||||||
|
dashboards = fields.Nested(ChartLineageDownstreamDashboardsSchema)
|
||||||
|
|
||||||
|
|
||||||
|
class ChartLineageResponseSchema(Schema):
|
||||||
|
chart = fields.Nested(ChartLineageChartSchema)
|
||||||
|
upstream = fields.Nested(ChartLineageUpstreamSchema)
|
||||||
|
downstream = fields.Nested(ChartLineageDownstreamSchema)
|
||||||
|
|
||||||
|
|
||||||
CHART_SCHEMAS = (
|
CHART_SCHEMAS = (
|
||||||
ChartCacheWarmUpRequestSchema,
|
ChartCacheWarmUpRequestSchema,
|
||||||
ChartCacheWarmUpResponseSchema,
|
ChartCacheWarmUpResponseSchema,
|
||||||
@@ -1851,4 +1898,5 @@ CHART_SCHEMAS = (
|
|||||||
ChartGetResponseSchema,
|
ChartGetResponseSchema,
|
||||||
ChartCacheScreenshotResponseSchema,
|
ChartCacheScreenshotResponseSchema,
|
||||||
GetFavStarIdsSchema,
|
GetFavStarIdsSchema,
|
||||||
|
ChartLineageResponseSchema,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
|||||||
"put_colors": "write",
|
"put_colors": "write",
|
||||||
"sync_permissions": "write",
|
"sync_permissions": "write",
|
||||||
"restore": "write",
|
"restore": "write",
|
||||||
|
"lineage": "read",
|
||||||
}
|
}
|
||||||
|
|
||||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ from superset.dashboards.schemas import (
|
|||||||
DashboardCopySchema,
|
DashboardCopySchema,
|
||||||
DashboardDatasetSchema,
|
DashboardDatasetSchema,
|
||||||
DashboardGetResponseSchema,
|
DashboardGetResponseSchema,
|
||||||
|
DashboardLineageResponseSchema,
|
||||||
DashboardNativeFiltersConfigUpdateSchema,
|
DashboardNativeFiltersConfigUpdateSchema,
|
||||||
DashboardPostSchema,
|
DashboardPostSchema,
|
||||||
DashboardPutSchema,
|
DashboardPutSchema,
|
||||||
@@ -251,6 +252,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
|||||||
"put_chart_customizations",
|
"put_chart_customizations",
|
||||||
"put_colors",
|
"put_colors",
|
||||||
"export_as_example",
|
"export_as_example",
|
||||||
|
"lineage",
|
||||||
}
|
}
|
||||||
resource_name = "dashboard"
|
resource_name = "dashboard"
|
||||||
allow_browser_login = True
|
allow_browser_login = True
|
||||||
@@ -428,6 +430,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
|||||||
DashboardCacheScreenshotResponseSchema,
|
DashboardCacheScreenshotResponseSchema,
|
||||||
DashboardCopySchema,
|
DashboardCopySchema,
|
||||||
DashboardGetResponseSchema,
|
DashboardGetResponseSchema,
|
||||||
|
DashboardLineageResponseSchema,
|
||||||
DashboardDatasetSchema,
|
DashboardDatasetSchema,
|
||||||
TabsPayloadSchema,
|
TabsPayloadSchema,
|
||||||
GetFavStarIdsSchema,
|
GetFavStarIdsSchema,
|
||||||
@@ -525,6 +528,126 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
|||||||
)
|
)
|
||||||
return self.response(200, result=result)
|
return self.response(200, result=result)
|
||||||
|
|
||||||
|
@expose("/<id_or_slug>/lineage", methods=("GET",))
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@statsd_metrics
|
||||||
|
@with_dashboard
|
||||||
|
@event_logger.log_this_with_context(
|
||||||
|
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
|
||||||
|
log_to_statsd=False,
|
||||||
|
)
|
||||||
|
# pylint: disable=arguments-differ,arguments-renamed
|
||||||
|
def lineage(self, dash: Dashboard) -> Response:
|
||||||
|
"""Get lineage information for a dashboard.
|
||||||
|
---
|
||||||
|
get:
|
||||||
|
summary: Get lineage information for a dashboard
|
||||||
|
description: >-
|
||||||
|
Returns upstream (charts, datasets, databases) lineage information
|
||||||
|
for a dashboard
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id_or_slug
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Either the id of the dashboard, or its slug
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Lineage information
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DashboardLineageResponseSchema"
|
||||||
|
401:
|
||||||
|
$ref: '#/components/responses/401'
|
||||||
|
404:
|
||||||
|
$ref: '#/components/responses/404'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/500'
|
||||||
|
"""
|
||||||
|
dashboard_info = {
|
||||||
|
"id": dash.id,
|
||||||
|
"title": dash.dashboard_title,
|
||||||
|
"slug": dash.slug,
|
||||||
|
"published": dash.published,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get upstream (charts, datasets, databases) information
|
||||||
|
charts = []
|
||||||
|
dataset_map = {}
|
||||||
|
database_map = {}
|
||||||
|
|
||||||
|
for chart in dash.slices:
|
||||||
|
charts.append(
|
||||||
|
{
|
||||||
|
"id": chart.id,
|
||||||
|
"slice_name": chart.slice_name,
|
||||||
|
"viz_type": chart.viz_type,
|
||||||
|
"dataset_id": chart.datasource_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect dataset information. Schema/table/database details are
|
||||||
|
# only exposed to users who can access the underlying datasource;
|
||||||
|
# otherwise they are redacted so lineage never leaks datasource
|
||||||
|
# internals (the dataset id/name are kept so the graph still
|
||||||
|
# renders).
|
||||||
|
dataset = chart.datasource
|
||||||
|
if dataset and dataset.id not in dataset_map:
|
||||||
|
can_access = security_manager.can_access_datasource(dataset)
|
||||||
|
dataset_map[dataset.id] = {
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"database_id": dataset.database_id if can_access else None,
|
||||||
|
"database_name": (
|
||||||
|
dataset.database.database_name
|
||||||
|
if can_access and dataset.database
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"schema": dataset.schema if can_access else None,
|
||||||
|
"table_name": dataset.table_name if can_access else None,
|
||||||
|
"chart_ids": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if dataset and dataset.id in dataset_map:
|
||||||
|
dataset_map[dataset.id]["chart_ids"].append(chart.id)
|
||||||
|
|
||||||
|
# Collect database information, only for accessible datasources
|
||||||
|
if (
|
||||||
|
dataset
|
||||||
|
and security_manager.can_access_datasource(dataset)
|
||||||
|
and dataset.database
|
||||||
|
and dataset.database.id not in database_map
|
||||||
|
):
|
||||||
|
database_map[dataset.database.id] = {
|
||||||
|
"id": dataset.database.id,
|
||||||
|
"database_name": dataset.database.database_name,
|
||||||
|
"backend": dataset.database.backend,
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream = {
|
||||||
|
"charts": {
|
||||||
|
"count": len(charts),
|
||||||
|
"result": charts,
|
||||||
|
},
|
||||||
|
"datasets": {
|
||||||
|
"count": len(dataset_map),
|
||||||
|
"result": list(dataset_map.values()),
|
||||||
|
},
|
||||||
|
"databases": {
|
||||||
|
"count": len(database_map),
|
||||||
|
"result": list(database_map.values()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dashboard": dashboard_info,
|
||||||
|
"upstream": upstream,
|
||||||
|
"downstream": None,
|
||||||
|
}
|
||||||
|
return self.response(200, result=result)
|
||||||
|
|
||||||
@expose("/<id_or_slug>/datasets", methods=("GET",))
|
@expose("/<id_or_slug>/datasets", methods=("GET",))
|
||||||
@protect()
|
@protect()
|
||||||
@handle_api_exception
|
@handle_api_exception
|
||||||
|
|||||||
@@ -623,3 +623,60 @@ class CacheScreenshotSchema(Schema):
|
|||||||
fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False
|
fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False
|
||||||
)
|
)
|
||||||
permalinkKey = fields.Str(required=False) # noqa: N815
|
permalinkKey = fields.Str(required=False) # noqa: N815
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageDashboardSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
title = fields.String()
|
||||||
|
slug = fields.String()
|
||||||
|
published = fields.Boolean()
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageChartSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
slice_name = fields.String()
|
||||||
|
viz_type = fields.String()
|
||||||
|
dataset_id = fields.Integer()
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageDatasetSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
name = fields.String()
|
||||||
|
database_id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
schema = fields.String(allow_none=True)
|
||||||
|
table_name = fields.String()
|
||||||
|
chart_ids = fields.List(fields.Integer())
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageDatabaseSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
backend = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageUpstreamChartsSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(DashboardLineageChartSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageUpstreamDatasetsSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(DashboardLineageDatasetSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageUpstreamDatabasesSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(DashboardLineageDatabaseSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageUpstreamSchema(Schema):
|
||||||
|
charts = fields.Nested(DashboardLineageUpstreamChartsSchema)
|
||||||
|
datasets = fields.Nested(DashboardLineageUpstreamDatasetsSchema)
|
||||||
|
databases = fields.Nested(DashboardLineageUpstreamDatabasesSchema)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLineageResponseSchema(Schema):
|
||||||
|
dashboard = fields.Nested(DashboardLineageDashboardSchema)
|
||||||
|
upstream = fields.Nested(DashboardLineageUpstreamSchema)
|
||||||
|
downstream = fields.Field(allow_none=True)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from superset.datasets.schemas import (
|
|||||||
DatasetCacheWarmUpResponseSchema,
|
DatasetCacheWarmUpResponseSchema,
|
||||||
DatasetDrillInfoSchema,
|
DatasetDrillInfoSchema,
|
||||||
DatasetDuplicateSchema,
|
DatasetDuplicateSchema,
|
||||||
|
DatasetLineageResponseSchema,
|
||||||
DatasetPostSchema,
|
DatasetPostSchema,
|
||||||
DatasetPutSchema,
|
DatasetPutSchema,
|
||||||
DatasetRelatedObjectsResponse,
|
DatasetRelatedObjectsResponse,
|
||||||
@@ -111,6 +112,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"get_or_create_dataset",
|
"get_or_create_dataset",
|
||||||
"warm_up_cache",
|
"warm_up_cache",
|
||||||
"get_drill_info",
|
"get_drill_info",
|
||||||
|
"lineage",
|
||||||
}
|
}
|
||||||
list_columns = [
|
list_columns = [
|
||||||
"id",
|
"id",
|
||||||
@@ -299,6 +301,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
DatasetRelatedObjectsResponse,
|
DatasetRelatedObjectsResponse,
|
||||||
DatasetDuplicateSchema,
|
DatasetDuplicateSchema,
|
||||||
GetOrCreateDatasetSchema,
|
GetOrCreateDatasetSchema,
|
||||||
|
DatasetLineageResponseSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
openapi_spec_methods = openapi_spec_methods_override
|
openapi_spec_methods = openapi_spec_methods_override
|
||||||
@@ -852,6 +855,129 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
dashboards={"count": len(dashboards), "result": dashboards},
|
dashboards={"count": len(dashboards), "result": dashboards},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@expose("/<id_or_uuid>/lineage", methods=("GET",))
|
||||||
|
@protect()
|
||||||
|
@safe
|
||||||
|
@statsd_metrics
|
||||||
|
@event_logger.log_this_with_context(
|
||||||
|
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
|
||||||
|
log_to_statsd=False,
|
||||||
|
)
|
||||||
|
def lineage(self, id_or_uuid: str) -> Response:
|
||||||
|
"""Get lineage information for a dataset.
|
||||||
|
---
|
||||||
|
get:
|
||||||
|
summary: Get lineage information for a dataset
|
||||||
|
description: >-
|
||||||
|
Returns upstream (database) and downstream (charts, dashboards) lineage
|
||||||
|
information for a dataset
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id_or_uuid
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Either the id of the dataset, or its uuid
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Lineage information
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/DatasetLineageResponseSchema"
|
||||||
|
401:
|
||||||
|
$ref: '#/components/responses/401'
|
||||||
|
404:
|
||||||
|
$ref: '#/components/responses/404'
|
||||||
|
500:
|
||||||
|
$ref: '#/components/responses/500'
|
||||||
|
"""
|
||||||
|
dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid)
|
||||||
|
if not dataset:
|
||||||
|
return self.response_404()
|
||||||
|
|
||||||
|
dataset_info = {
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"database_id": dataset.database_id,
|
||||||
|
"database_name": (
|
||||||
|
dataset.database.database_name if dataset.database else None
|
||||||
|
),
|
||||||
|
"schema": dataset.schema,
|
||||||
|
"table_name": dataset.table_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get upstream (database) information
|
||||||
|
upstream: dict[str, Any] = {}
|
||||||
|
if dataset.database:
|
||||||
|
upstream["database"] = {
|
||||||
|
"id": dataset.database.id,
|
||||||
|
"database_name": dataset.database.database_name,
|
||||||
|
"backend": dataset.database.backend,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
upstream["database"] = None
|
||||||
|
|
||||||
|
# Get downstream (charts and dashboards) information
|
||||||
|
related_data = DatasetDAO.get_related_objects(dataset.id)
|
||||||
|
|
||||||
|
# Build chart information with dashboard IDs, filtering both the charts
|
||||||
|
# and their linked dashboards by the current user's permissions so
|
||||||
|
# lineage never exposes assets the user cannot access.
|
||||||
|
charts = []
|
||||||
|
for chart in related_data["charts"]:
|
||||||
|
if not security_manager.can_access_chart(chart):
|
||||||
|
continue
|
||||||
|
dashboard_ids = [
|
||||||
|
d.id
|
||||||
|
for d in chart.dashboards
|
||||||
|
if security_manager.can_access_dashboard(d)
|
||||||
|
]
|
||||||
|
charts.append(
|
||||||
|
{
|
||||||
|
"id": chart.id,
|
||||||
|
"slice_name": chart.slice_name,
|
||||||
|
"viz_type": chart.viz_type,
|
||||||
|
"dashboard_ids": dashboard_ids,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build dashboard information with chart IDs
|
||||||
|
dashboards = []
|
||||||
|
for dashboard in related_data["dashboards"]:
|
||||||
|
if not security_manager.can_access_dashboard(dashboard):
|
||||||
|
continue
|
||||||
|
chart_ids = [
|
||||||
|
chart.id
|
||||||
|
for chart in dashboard.slices
|
||||||
|
if chart.datasource_id == dataset.id
|
||||||
|
]
|
||||||
|
dashboards.append(
|
||||||
|
{
|
||||||
|
"id": dashboard.id,
|
||||||
|
"title": dashboard.dashboard_title,
|
||||||
|
"slug": dashboard.slug,
|
||||||
|
"chart_ids": chart_ids,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
downstream = {
|
||||||
|
"charts": {
|
||||||
|
"count": len(charts),
|
||||||
|
"result": charts,
|
||||||
|
},
|
||||||
|
"dashboards": {
|
||||||
|
"count": len(dashboards),
|
||||||
|
"result": dashboards,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"dataset": dataset_info,
|
||||||
|
"upstream": upstream,
|
||||||
|
"downstream": downstream,
|
||||||
|
}
|
||||||
|
return self.response(200, result=result)
|
||||||
|
|
||||||
@expose("/", methods=("DELETE",))
|
@expose("/", methods=("DELETE",))
|
||||||
@protect()
|
@protect()
|
||||||
@safe
|
@safe
|
||||||
|
|||||||
@@ -250,6 +250,60 @@ class DatasetRelatedObjectsResponse(Schema):
|
|||||||
dashboards = fields.Nested(DatasetRelatedDashboards)
|
dashboards = fields.Nested(DatasetRelatedDashboards)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDatasetSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
name = fields.String()
|
||||||
|
database_id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
schema = fields.String(allow_none=True)
|
||||||
|
table_name = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDatabaseSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
database_name = fields.String()
|
||||||
|
backend = fields.String()
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageChartSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
slice_name = fields.String()
|
||||||
|
viz_type = fields.String()
|
||||||
|
dashboard_ids = fields.List(fields.Integer())
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDashboardSchema(Schema):
|
||||||
|
id = fields.Integer()
|
||||||
|
title = fields.String()
|
||||||
|
slug = fields.String()
|
||||||
|
chart_ids = fields.List(fields.Integer())
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageUpstreamSchema(Schema):
|
||||||
|
database = fields.Nested(DatasetLineageDatabaseSchema, allow_none=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDownstreamChartsSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(DatasetLineageChartSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDownstreamDashboardsSchema(Schema):
|
||||||
|
count = fields.Integer()
|
||||||
|
result = fields.List(fields.Nested(DatasetLineageDashboardSchema))
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageDownstreamSchema(Schema):
|
||||||
|
charts = fields.Nested(DatasetLineageDownstreamChartsSchema)
|
||||||
|
dashboards = fields.Nested(DatasetLineageDownstreamDashboardsSchema)
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetLineageResponseSchema(Schema):
|
||||||
|
dataset = fields.Nested(DatasetLineageDatasetSchema)
|
||||||
|
upstream = fields.Nested(DatasetLineageUpstreamSchema)
|
||||||
|
downstream = fields.Nested(DatasetLineageDownstreamSchema)
|
||||||
|
|
||||||
|
|
||||||
class ImportV1ColumnSchema(Schema):
|
class ImportV1ColumnSchema(Schema):
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
@pre_load
|
@pre_load
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|||||||
load_birth_names_dashboard_with_slices, # noqa: F401
|
load_birth_names_dashboard_with_slices, # noqa: F401
|
||||||
load_birth_names_data, # noqa: F401
|
load_birth_names_data, # noqa: F401
|
||||||
)
|
)
|
||||||
|
from tests.integration_tests.fixtures.client import client # noqa: F401
|
||||||
from tests.integration_tests.fixtures.energy_dashboard import (
|
from tests.integration_tests.fixtures.energy_dashboard import (
|
||||||
load_energy_table_data, # noqa: F401
|
load_energy_table_data, # noqa: F401
|
||||||
load_energy_table_with_slice, # noqa: F401
|
load_energy_table_with_slice, # noqa: F401
|
||||||
@@ -59,6 +60,10 @@ from tests.integration_tests.fixtures.importexport import (
|
|||||||
database_config,
|
database_config,
|
||||||
dataset_config,
|
dataset_config,
|
||||||
)
|
)
|
||||||
|
from tests.integration_tests.fixtures.lineage import (
|
||||||
|
inject_expected_chart_lineage, # noqa: F401
|
||||||
|
lineage_test_data, # noqa: F401
|
||||||
|
)
|
||||||
from tests.integration_tests.fixtures.tags import (
|
from tests.integration_tests.fixtures.tags import (
|
||||||
create_custom_tags, # noqa: F401
|
create_custom_tags, # noqa: F401
|
||||||
get_filter_params,
|
get_filter_params,
|
||||||
@@ -2447,3 +2452,30 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
|||||||
self.login(ADMIN_USERNAME)
|
self.login(ADMIN_USERNAME)
|
||||||
rv = self.client.get("api/v1/chart/related/owners")
|
rv = self.client.get("api/v1/chart/related/owners")
|
||||||
assert rv.status_code == 200
|
assert rv.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("inject_expected_chart_lineage")
|
||||||
|
def test_get_chart_lineage(self):
|
||||||
|
"""
|
||||||
|
Chart API: Test get chart lineage
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
chart_id = self.chart_lineage["chart_id"]
|
||||||
|
expected = self.chart_lineage["expected"]
|
||||||
|
|
||||||
|
uri = f"api/v1/chart/{chart_id}/lineage"
|
||||||
|
rv = self.get_assert_metric(uri, "lineage")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(rv.data.decode("utf-8"))
|
||||||
|
|
||||||
|
# The lineage payload is wrapped under "result"
|
||||||
|
assert data["result"] == expected
|
||||||
|
|
||||||
|
def test_get_chart_lineage_not_found(self):
|
||||||
|
"""
|
||||||
|
Chart API: Test get chart lineage with non-existent chart
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = "api/v1/chart/99999/lineage"
|
||||||
|
rv = self.client.get(uri)
|
||||||
|
assert rv.status_code == 404
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|||||||
load_birth_names_dashboard_with_slices, # noqa: F401
|
load_birth_names_dashboard_with_slices, # noqa: F401
|
||||||
load_birth_names_data, # noqa: F401
|
load_birth_names_data, # noqa: F401
|
||||||
)
|
)
|
||||||
|
from tests.integration_tests.fixtures.client import client # noqa: F401
|
||||||
|
from tests.integration_tests.fixtures.lineage import (
|
||||||
|
inject_expected_dashboard_lineage, # noqa: F401
|
||||||
|
lineage_test_data, # noqa: F401
|
||||||
|
)
|
||||||
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||||
load_world_bank_dashboard_with_slices, # noqa: F401
|
load_world_bank_dashboard_with_slices, # noqa: F401
|
||||||
load_world_bank_data, # noqa: F401
|
load_world_bank_data, # noqa: F401
|
||||||
@@ -4116,6 +4121,33 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
|
|||||||
db.session.delete(dashboard)
|
db.session.delete(dashboard)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("inject_expected_dashboard_lineage")
|
||||||
|
def test_get_dashboard_lineage(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test get dashboard lineage
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
dashboard_id = self.dashboard_lineage["dashboard_id"]
|
||||||
|
expected = self.dashboard_lineage["expected"]
|
||||||
|
|
||||||
|
uri = f"api/v1/dashboard/{dashboard_id}/lineage"
|
||||||
|
rv = self.get_assert_metric(uri, "lineage")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(rv.data.decode("utf-8"))
|
||||||
|
|
||||||
|
# The lineage payload is wrapped under "result"
|
||||||
|
assert data["result"] == expected
|
||||||
|
|
||||||
|
def test_get_dashboard_lineage_not_found(self):
|
||||||
|
"""
|
||||||
|
Dashboard API: Test get dashboard lineage with non-existent dashboard
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = "api/v1/dashboard/99999/lineage"
|
||||||
|
rv = self.client.get(uri)
|
||||||
|
assert rv.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestDashboardCustomTagsFiltering(SupersetTestCase):
|
class TestDashboardCustomTagsFiltering(SupersetTestCase):
|
||||||
"""Test dashboard list API tags field behavior.
|
"""Test dashboard list API tags field behavior.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
|
|||||||
load_birth_names_dashboard_with_slices, # noqa: F401
|
load_birth_names_dashboard_with_slices, # noqa: F401
|
||||||
load_birth_names_data, # noqa: F401
|
load_birth_names_data, # noqa: F401
|
||||||
)
|
)
|
||||||
|
from tests.integration_tests.fixtures.client import client # noqa: F401
|
||||||
from tests.integration_tests.fixtures.energy_dashboard import (
|
from tests.integration_tests.fixtures.energy_dashboard import (
|
||||||
load_energy_table_data, # noqa: F401
|
load_energy_table_data, # noqa: F401
|
||||||
load_energy_table_with_slice, # noqa: F401
|
load_energy_table_with_slice, # noqa: F401
|
||||||
@@ -64,6 +65,10 @@ from tests.integration_tests.fixtures.importexport import (
|
|||||||
dataset_config,
|
dataset_config,
|
||||||
dataset_ui_export,
|
dataset_ui_export,
|
||||||
)
|
)
|
||||||
|
from tests.integration_tests.fixtures.lineage import (
|
||||||
|
inject_expected_dataset_lineage, # noqa: F401
|
||||||
|
lineage_test_data, # noqa: F401
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestDatasetApi(SupersetTestCase):
|
class TestDatasetApi(SupersetTestCase):
|
||||||
@@ -3558,3 +3563,30 @@ class TestDatasetApi(SupersetTestCase):
|
|||||||
assert rv.status_code == 404
|
assert rv.status_code == 404
|
||||||
|
|
||||||
self.items_to_delete = [dashboard, chart, dataset]
|
self.items_to_delete = [dashboard, chart, dataset]
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("inject_expected_dataset_lineage")
|
||||||
|
def test_get_dataset_lineage(self):
|
||||||
|
"""
|
||||||
|
Dataset API: Test get dataset lineage
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
dataset_id = self.dataset_lineage["dataset_id"]
|
||||||
|
expected = self.dataset_lineage["expected"]
|
||||||
|
|
||||||
|
uri = f"api/v1/dataset/{dataset_id}/lineage"
|
||||||
|
rv = self.get_assert_metric(uri, "lineage")
|
||||||
|
assert rv.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(rv.data.decode("utf-8"))
|
||||||
|
|
||||||
|
# The lineage payload is wrapped under "result"
|
||||||
|
assert data["result"] == expected
|
||||||
|
|
||||||
|
def test_get_dataset_lineage_not_found(self):
|
||||||
|
"""
|
||||||
|
Dataset API: Test get dataset lineage with non-existent dataset
|
||||||
|
"""
|
||||||
|
self.login(ADMIN_USERNAME)
|
||||||
|
uri = "api/v1/dataset/99999/lineage"
|
||||||
|
rv = self.client.get(uri)
|
||||||
|
assert rv.status_code == 404
|
||||||
|
|||||||
266
tests/integration_tests/fixtures/lineage.py
Normal file
266
tests/integration_tests/fixtures/lineage.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
# or more contributor license agreements. See the NOTICE file
|
||||||
|
# distributed with this work for additional information
|
||||||
|
# regarding copyright ownership. The ASF licenses this file
|
||||||
|
# to you under the Apache License, Version 2.0 (the
|
||||||
|
# "License"); you may not use this file except in compliance
|
||||||
|
# with the License. You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing,
|
||||||
|
# software distributed under the License is distributed on an
|
||||||
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
# KIND, either express or implied. See the License for the
|
||||||
|
# specific language governing permissions and limitations
|
||||||
|
# under the License.
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from superset import db
|
||||||
|
from superset.models.dashboard import Dashboard
|
||||||
|
from superset.models.slice import Slice
|
||||||
|
from superset.utils.database import get_example_database
|
||||||
|
from tests.integration_tests.dashboard_utils import create_table_metadata
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lineage_test_data(app_context, load_birth_names_data):
|
||||||
|
"""
|
||||||
|
Base fixture that creates a simple lineage structure and returns
|
||||||
|
the created entities (database, dataset, charts, dashboard).
|
||||||
|
"""
|
||||||
|
database = get_example_database()
|
||||||
|
|
||||||
|
# Create dataset
|
||||||
|
dataset = create_table_metadata(
|
||||||
|
table_name="lineage_test_dataset",
|
||||||
|
database=database,
|
||||||
|
)
|
||||||
|
db.session.add(dataset)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Create charts
|
||||||
|
chart1 = Slice(
|
||||||
|
slice_name="Lineage Test Chart 1",
|
||||||
|
viz_type="table",
|
||||||
|
datasource_id=dataset.id,
|
||||||
|
datasource_type="table",
|
||||||
|
params="{}",
|
||||||
|
)
|
||||||
|
chart2 = Slice(
|
||||||
|
slice_name="Lineage Test Chart 2",
|
||||||
|
viz_type="pie",
|
||||||
|
datasource_id=dataset.id,
|
||||||
|
datasource_type="table",
|
||||||
|
params="{}",
|
||||||
|
)
|
||||||
|
db.session.add(chart1)
|
||||||
|
db.session.add(chart2)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Create dashboard with charts
|
||||||
|
dashboard = Dashboard(
|
||||||
|
dashboard_title="Lineage Test Dashboard",
|
||||||
|
slug="lineage-test-dashboard",
|
||||||
|
slices=[chart1, chart2],
|
||||||
|
published=True,
|
||||||
|
)
|
||||||
|
db.session.add(dashboard)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Return the created entities
|
||||||
|
result = {
|
||||||
|
"database": database,
|
||||||
|
"dataset": dataset,
|
||||||
|
"charts": [chart1, chart2],
|
||||||
|
"dashboard": dashboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
yield result
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
db.session.delete(dashboard)
|
||||||
|
db.session.delete(chart1)
|
||||||
|
db.session.delete(chart2)
|
||||||
|
for col in dataset.columns + dataset.metrics:
|
||||||
|
db.session.delete(col)
|
||||||
|
db.session.delete(dataset)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=False)
|
||||||
|
def inject_expected_dataset_lineage(request, lineage_test_data):
|
||||||
|
"""
|
||||||
|
Injects dataset lineage data into test class instance.
|
||||||
|
"""
|
||||||
|
dataset = lineage_test_data["dataset"]
|
||||||
|
database = lineage_test_data["database"]
|
||||||
|
charts = lineage_test_data["charts"]
|
||||||
|
dashboard = lineage_test_data["dashboard"]
|
||||||
|
|
||||||
|
request.instance.dataset_lineage = {
|
||||||
|
"dataset_id": dataset.id,
|
||||||
|
"expected": {
|
||||||
|
"dataset": {
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"schema": dataset.schema,
|
||||||
|
"table_name": dataset.table_name,
|
||||||
|
"database_id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"database": {
|
||||||
|
"id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
"backend": database.backend,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downstream": {
|
||||||
|
"charts": {
|
||||||
|
"count": 2,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": charts[0].id,
|
||||||
|
"slice_name": charts[0].slice_name,
|
||||||
|
"viz_type": charts[0].viz_type,
|
||||||
|
"dashboard_ids": [dashboard.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": charts[1].id,
|
||||||
|
"slice_name": charts[1].slice_name,
|
||||||
|
"viz_type": charts[1].viz_type,
|
||||||
|
"dashboard_ids": [dashboard.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"dashboards": {
|
||||||
|
"count": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": dashboard.id,
|
||||||
|
"title": dashboard.dashboard_title,
|
||||||
|
"slug": dashboard.slug,
|
||||||
|
"chart_ids": sorted([charts[0].id, charts[1].id]),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=False)
|
||||||
|
def inject_expected_chart_lineage(request, lineage_test_data):
|
||||||
|
"""
|
||||||
|
Injects chart lineage data into test class instance.
|
||||||
|
"""
|
||||||
|
dataset = lineage_test_data["dataset"]
|
||||||
|
database = lineage_test_data["database"]
|
||||||
|
chart = lineage_test_data["charts"][0] # Use first chart
|
||||||
|
dashboard = lineage_test_data["dashboard"]
|
||||||
|
|
||||||
|
request.instance.chart_lineage = {
|
||||||
|
"chart_id": chart.id,
|
||||||
|
"expected": {
|
||||||
|
"chart": {
|
||||||
|
"id": chart.id,
|
||||||
|
"slice_name": chart.slice_name,
|
||||||
|
"viz_type": chart.viz_type,
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"dataset": {
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"schema": dataset.schema,
|
||||||
|
"table_name": dataset.table_name,
|
||||||
|
"database_id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
"backend": database.backend,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"downstream": {
|
||||||
|
"dashboards": {
|
||||||
|
"count": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": dashboard.id,
|
||||||
|
"title": dashboard.dashboard_title,
|
||||||
|
"slug": dashboard.slug,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=False)
|
||||||
|
def inject_expected_dashboard_lineage(request, lineage_test_data):
|
||||||
|
"""
|
||||||
|
Injects dashboard lineage data into test class instance.
|
||||||
|
"""
|
||||||
|
dataset = lineage_test_data["dataset"]
|
||||||
|
database = lineage_test_data["database"]
|
||||||
|
charts = lineage_test_data["charts"]
|
||||||
|
dashboard = lineage_test_data["dashboard"]
|
||||||
|
|
||||||
|
request.instance.dashboard_lineage = {
|
||||||
|
"dashboard_id": dashboard.id,
|
||||||
|
"expected": {
|
||||||
|
"dashboard": {
|
||||||
|
"id": dashboard.id,
|
||||||
|
"title": dashboard.dashboard_title,
|
||||||
|
"slug": dashboard.slug,
|
||||||
|
"published": dashboard.published,
|
||||||
|
},
|
||||||
|
"upstream": {
|
||||||
|
"charts": {
|
||||||
|
"count": 2,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": charts[0].id,
|
||||||
|
"slice_name": charts[0].slice_name,
|
||||||
|
"viz_type": charts[0].viz_type,
|
||||||
|
"dataset_id": dataset.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": charts[1].id,
|
||||||
|
"slice_name": charts[1].slice_name,
|
||||||
|
"viz_type": charts[1].viz_type,
|
||||||
|
"dataset_id": dataset.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"datasets": {
|
||||||
|
"count": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": dataset.id,
|
||||||
|
"name": dataset.name,
|
||||||
|
"schema": dataset.schema,
|
||||||
|
"table_name": dataset.table_name,
|
||||||
|
"database_id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
"chart_ids": sorted([charts[0].id, charts[1].id]),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"databases": {
|
||||||
|
"count": 1,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"id": database.id,
|
||||||
|
"database_name": database.database_name,
|
||||||
|
"backend": database.backend,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"downstream": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user