mirror of
https://github.com/apache/superset.git
synced 2026-06-11 10:39:15 +00:00
Compare commits
4 Commits
fix/chart-
...
adopt/line
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766b4a8014 | ||
|
|
f8f4010ca5 | ||
|
|
4326563a22 | ||
|
|
23123ce6d7 |
@@ -89,6 +89,8 @@ import {
|
||||
import { DatabaseSelector } from '../../../DatabaseSelector';
|
||||
import CollectionTable from '../CollectionTable';
|
||||
import Fieldset from '../Fieldset';
|
||||
import { useDatasetLineage } from 'src/hooks/apiResources';
|
||||
import { LineageView } from 'src/features/lineage';
|
||||
import Field from '../Field';
|
||||
import { fetchSyncedColumns, updateColumns } from '../../utils';
|
||||
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 ?? 0);
|
||||
if (!datasourceId) {
|
||||
return <Loading />;
|
||||
}
|
||||
return <LineageView lineageResource={lineageResource} entityType="dataset" />;
|
||||
}
|
||||
|
||||
const DefaultColumnSettingsContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
margin-bottom: ${theme.sizeUnit * 4}px;
|
||||
@@ -477,6 +489,7 @@ const TABS_KEYS = {
|
||||
COLUMNS: 'COLUMNS',
|
||||
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
|
||||
USAGE: 'USAGE',
|
||||
LINEAGE: 'LINEAGE',
|
||||
FOLDERS: 'FOLDERS',
|
||||
SETTINGS: 'SETTINGS',
|
||||
SPATIAL: 'SPATIAL',
|
||||
@@ -2515,6 +2528,15 @@ class DatasourceEditor extends PureComponent<
|
||||
</StyledTableTabWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TABS_KEYS.LINEAGE,
|
||||
label: t('Lineage'),
|
||||
children: (
|
||||
<StyledTableTabWrapper>
|
||||
<DatasetLineageTab datasourceId={datasource.id} />
|
||||
</StyledTableTabWrapper>
|
||||
),
|
||||
},
|
||||
...(isFeatureEnabled(FeatureFlag.DatasetFolders)
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { LineageModal } from 'src/features/lineage';
|
||||
|
||||
export const useHeaderActionsMenu = ({
|
||||
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
|
||||
if (editMode) {
|
||||
menuItems.push({
|
||||
|
||||
@@ -393,4 +393,5 @@ export enum MenuKeys {
|
||||
ManageEmailReports = 'manage_email_reports',
|
||||
ExportPivotXlsx = 'export_pivot_xlsx',
|
||||
EmbedCode = 'embed_code',
|
||||
ViewLineage = 'view_lineage',
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ import { ReportObject } from 'src/features/reports/types';
|
||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||
import EmbedCodeContent from '../EmbedCodeContent';
|
||||
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
||||
import { LineageModal } from 'src/features/lineage';
|
||||
|
||||
export const SEARCH_THRESHOLD = 10;
|
||||
|
||||
@@ -102,6 +103,7 @@ const MENU_KEYS = {
|
||||
EDIT_REPORT: 'edit_report',
|
||||
DELETE_REPORT: 'delete_report',
|
||||
VIEW_QUERY: 'view_query',
|
||||
VIEW_LINEAGE: 'view_lineage',
|
||||
RUN_IN_SQL_LAB: 'run_in_sql_lab',
|
||||
};
|
||||
|
||||
@@ -1028,6 +1030,23 @@ export const useExploreAdditionalActionsMenu = (
|
||||
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
|
||||
if (datasource) {
|
||||
menuItems.push({
|
||||
|
||||
@@ -36,6 +36,7 @@ import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
|
||||
import { TableTab } from 'src/views/CRUD/types';
|
||||
import { LineageModal } from 'src/features/lineage';
|
||||
|
||||
interface ChartCardProps {
|
||||
chart: Chart;
|
||||
@@ -76,6 +77,7 @@ export default function ChartCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const canRead = hasPerm('can_read');
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
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) {
|
||||
menuItems.push({
|
||||
key: 'export',
|
||||
@@ -167,54 +192,59 @@ export default function ChartCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled && chart.url) {
|
||||
history.push(chart.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListViewCard
|
||||
loading={loading}
|
||||
title={chart.slice_name}
|
||||
certifiedBy={chart.certified_by}
|
||||
certificationDetails={chart.certification_details}
|
||||
cover={
|
||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||
<></>
|
||||
) : null
|
||||
}
|
||||
url={bulkSelectEnabled ? undefined : chart.url}
|
||||
imgURL={chart.thumbnail_url || ''}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/chart-card-fallback.svg',
|
||||
)}
|
||||
description={t('Modified %s', chart.changed_on_delta_humanized)}
|
||||
coverLeft={<FacePile users={chart.owners || []} />}
|
||||
coverRight={<Label>{chart.datasource_name_text}</Label>}
|
||||
linkComponent={Link}
|
||||
actions={
|
||||
<ListViewCard.Actions
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{userId && (
|
||||
<FaveStar
|
||||
itemId={chart.id}
|
||||
saveFaveStar={saveFavoriteStatus}
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
|
||||
<Button buttonSize="xsmall" type="link" buttonStyle="link">
|
||||
<Icons.MoreOutlined iconSize="xl" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
</CardStyles>
|
||||
<>
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled && chart.url) {
|
||||
history.push(chart.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListViewCard
|
||||
loading={loading}
|
||||
title={chart.slice_name}
|
||||
certifiedBy={chart.certified_by}
|
||||
certificationDetails={chart.certification_details}
|
||||
cover={
|
||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||
<></>
|
||||
) : null
|
||||
}
|
||||
url={bulkSelectEnabled ? undefined : chart.url}
|
||||
imgURL={chart.thumbnail_url || ''}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/chart-card-fallback.svg',
|
||||
)}
|
||||
description={t('Modified %s', chart.changed_on_delta_humanized)}
|
||||
coverLeft={<FacePile users={chart.owners || []} />}
|
||||
coverRight={<Label>{chart.datasource_name_text}</Label>}
|
||||
linkComponent={Link}
|
||||
actions={
|
||||
<ListViewCard.Actions
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{userId && (
|
||||
<FaveStar
|
||||
itemId={chart.id}
|
||||
saveFaveStar={saveFavoriteStatus}
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click', 'hover']}
|
||||
>
|
||||
<Button buttonSize="xsmall" type="link" buttonStyle="link">
|
||||
<Icons.MoreOutlined iconSize="xl" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
</CardStyles>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Dashboard } from 'src/views/CRUD/types';
|
||||
import { assetUrl } from 'src/utils/assetUrl';
|
||||
import { FacePile } from 'src/components';
|
||||
import { LineageModal } from 'src/features/lineage';
|
||||
|
||||
interface DashboardCardProps {
|
||||
isChart?: boolean;
|
||||
@@ -69,6 +70,7 @@ function DashboardCard({
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canDelete = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
const canRead = hasPerm('can_read');
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [fetchingThumbnail, setFetchingThumbnail] = useState<boolean>(false);
|
||||
|
||||
@@ -99,6 +101,23 @@ function DashboardCard({
|
||||
|
||||
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) {
|
||||
menuItems.push({
|
||||
key: 'edit',
|
||||
@@ -151,55 +170,60 @@ function DashboardCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled) {
|
||||
history.push(dashboard.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListViewCard
|
||||
loading={dashboard.loading || false}
|
||||
title={dashboard.dashboard_title}
|
||||
certifiedBy={dashboard.certified_by}
|
||||
certificationDetails={dashboard.certification_details}
|
||||
titleRight={<PublishedLabel isPublished={dashboard.published} />}
|
||||
cover={
|
||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||
<></>
|
||||
) : null
|
||||
}
|
||||
url={bulkSelectEnabled ? undefined : dashboard.url}
|
||||
linkComponent={Link}
|
||||
imgURL={thumbnailUrl}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/dashboard-card-fallback.svg',
|
||||
)}
|
||||
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
|
||||
coverLeft={<FacePile users={dashboard.owners || []} />}
|
||||
actions={
|
||||
<ListViewCard.Actions
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{userId && (
|
||||
<FaveStar
|
||||
itemId={dashboard.id}
|
||||
saveFaveStar={saveFavoriteStatus}
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['hover', 'click']}>
|
||||
<Button buttonSize="xsmall" buttonStyle="link">
|
||||
<Icons.MoreOutlined iconSize="xl" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
</CardStyles>
|
||||
<>
|
||||
<CardStyles
|
||||
onClick={() => {
|
||||
if (!bulkSelectEnabled) {
|
||||
history.push(dashboard.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListViewCard
|
||||
loading={dashboard.loading || false}
|
||||
title={dashboard.dashboard_title}
|
||||
certifiedBy={dashboard.certified_by}
|
||||
certificationDetails={dashboard.certification_details}
|
||||
titleRight={<PublishedLabel isPublished={dashboard.published} />}
|
||||
cover={
|
||||
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
|
||||
<></>
|
||||
) : null
|
||||
}
|
||||
url={bulkSelectEnabled ? undefined : dashboard.url}
|
||||
linkComponent={Link}
|
||||
imgURL={thumbnailUrl}
|
||||
imgFallbackURL={assetUrl(
|
||||
'/static/assets/images/dashboard-card-fallback.svg',
|
||||
)}
|
||||
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
|
||||
coverLeft={<FacePile users={dashboard.owners || []} />}
|
||||
actions={
|
||||
<ListViewCard.Actions
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{userId && (
|
||||
<FaveStar
|
||||
itemId={dashboard.id}
|
||||
saveFaveStar={saveFavoriteStatus}
|
||||
isStarred={favoriteStatus}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['hover', 'click']}
|
||||
>
|
||||
<Button buttonSize="xsmall" buttonStyle="link">
|
||||
<Icons.MoreOutlined iconSize="xl" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</ListViewCard.Actions>
|
||||
}
|
||||
/>
|
||||
</CardStyles>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
|
||||
import { Badge } from '@superset-ui/core/components';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { useDatasetLineage } from 'src/hooks/apiResources';
|
||||
import { LineageView } from 'src/features/lineage';
|
||||
|
||||
const StyledTabs = styled(Tabs)`
|
||||
${({ theme }) => `
|
||||
@@ -51,16 +54,25 @@ const TRANSLATIONS = {
|
||||
USAGE_TEXT: t('Usage'),
|
||||
COLUMNS_TEXT: t('Columns'),
|
||||
METRICS_TEXT: t('Metrics'),
|
||||
LINEAGE_TEXT: t('Lineage'),
|
||||
};
|
||||
|
||||
const TABS_KEYS = {
|
||||
COLUMNS: 'COLUMNS',
|
||||
METRICS: 'METRICS',
|
||||
USAGE: 'USAGE',
|
||||
LINEAGE: 'LINEAGE',
|
||||
};
|
||||
|
||||
const EditPage = ({ id }: EditPageProps) => {
|
||||
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 = (
|
||||
<TabStyles>
|
||||
@@ -85,9 +97,23 @@ const EditPage = ({ id }: EditPageProps) => {
|
||||
label: usageTab,
|
||||
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;
|
||||
|
||||
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;
|
||||
728
superset-frontend/src/features/lineage/LineageView.tsx
Normal file
728
superset-frontend/src/features/lineage/LineageView.tsx
Normal file
@@ -0,0 +1,728 @@
|
||||
/**
|
||||
* 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, any>;
|
||||
};
|
||||
|
||||
// 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: any) => {
|
||||
if (params.dataType === 'node') {
|
||||
const nodeName = params.name;
|
||||
const nodeDetails = nodeDetailsMap.get(nodeName);
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -86,6 +86,7 @@ const initialState: LoadingState = {
|
||||
*/
|
||||
export function useApiResourceFullBody<RESULT>(
|
||||
endpoint: string,
|
||||
skip = false,
|
||||
): Resource<RESULT> {
|
||||
const [resource, setResource] = useState<Resource<RESULT>>(initialState);
|
||||
const cancelRef = useRef<() => void>(() => {});
|
||||
@@ -98,6 +99,12 @@ export function useApiResourceFullBody<RESULT>(
|
||||
// when this effect runs, the endpoint has changed.
|
||||
// cancel any current calls so that state doesn't get messed up.
|
||||
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;
|
||||
cancelRef.current = () => {
|
||||
cancelled = true;
|
||||
@@ -132,7 +139,7 @@ export function useApiResourceFullBody<RESULT>(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [endpoint]);
|
||||
}, [endpoint, skip]);
|
||||
|
||||
return resource;
|
||||
}
|
||||
@@ -181,9 +188,12 @@ const extractInnerResult = <T>(responseBody: { result: T }) =>
|
||||
*
|
||||
* @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(
|
||||
useApiResourceFullBody<{ result: RESULT }>(endpoint),
|
||||
useApiResourceFullBody<{ result: RESULT }>(endpoint, skip),
|
||||
extractInnerResult,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
export * from './catalogs';
|
||||
export * from './charts';
|
||||
export * from './dashboards';
|
||||
export * from './lineage';
|
||||
export * from './tables';
|
||||
export * from './schemas';
|
||||
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),
|
||||
);
|
||||
@@ -131,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"screenshot",
|
||||
"cache_screenshot",
|
||||
"warm_up_cache",
|
||||
"lineage",
|
||||
}
|
||||
class_permission_name = "Chart"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
@@ -313,6 +314,107 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
except ChartNotFoundError:
|
||||
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",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -1755,6 +1755,53 @@ class ChartGetResponseSchema(Schema):
|
||||
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 = (
|
||||
ChartCacheWarmUpRequestSchema,
|
||||
ChartCacheWarmUpResponseSchema,
|
||||
@@ -1782,4 +1829,5 @@ CHART_SCHEMAS = (
|
||||
ChartGetResponseSchema,
|
||||
ChartCacheScreenshotResponseSchema,
|
||||
GetFavStarIdsSchema,
|
||||
ChartLineageResponseSchema,
|
||||
)
|
||||
|
||||
@@ -179,6 +179,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
||||
"put_colors": "write",
|
||||
"sync_permissions": "write",
|
||||
"restore": "write",
|
||||
"lineage": "read",
|
||||
}
|
||||
|
||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||
|
||||
@@ -104,6 +104,7 @@ from superset.dashboards.schemas import (
|
||||
DashboardCopySchema,
|
||||
DashboardDatasetSchema,
|
||||
DashboardGetResponseSchema,
|
||||
DashboardLineageResponseSchema,
|
||||
DashboardNativeFiltersConfigUpdateSchema,
|
||||
DashboardPostSchema,
|
||||
DashboardPutSchema,
|
||||
@@ -252,6 +253,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
||||
"put_chart_customizations",
|
||||
"put_colors",
|
||||
"export_as_example",
|
||||
"lineage",
|
||||
}
|
||||
resource_name = "dashboard"
|
||||
allow_browser_login = True
|
||||
@@ -429,6 +431,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
||||
DashboardCacheScreenshotResponseSchema,
|
||||
DashboardCopySchema,
|
||||
DashboardGetResponseSchema,
|
||||
DashboardLineageResponseSchema,
|
||||
DashboardDatasetSchema,
|
||||
TabsPayloadSchema,
|
||||
GetFavStarIdsSchema,
|
||||
@@ -524,6 +527,126 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
||||
)
|
||||
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",))
|
||||
@protect()
|
||||
@handle_api_exception
|
||||
|
||||
@@ -558,3 +558,60 @@ class CacheScreenshotSchema(Schema):
|
||||
fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False
|
||||
)
|
||||
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,
|
||||
DatasetDrillInfoSchema,
|
||||
DatasetDuplicateSchema,
|
||||
DatasetLineageResponseSchema,
|
||||
DatasetPostSchema,
|
||||
DatasetPutSchema,
|
||||
DatasetRelatedObjectsResponse,
|
||||
@@ -111,6 +112,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
"get_or_create_dataset",
|
||||
"warm_up_cache",
|
||||
"get_drill_info",
|
||||
"lineage",
|
||||
}
|
||||
list_columns = [
|
||||
"id",
|
||||
@@ -299,6 +301,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
DatasetRelatedObjectsResponse,
|
||||
DatasetDuplicateSchema,
|
||||
GetOrCreateDatasetSchema,
|
||||
DatasetLineageResponseSchema,
|
||||
)
|
||||
|
||||
openapi_spec_methods = openapi_spec_methods_override
|
||||
@@ -846,6 +849,129 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
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",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -250,6 +250,60 @@ class DatasetRelatedObjectsResponse(Schema):
|
||||
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):
|
||||
# pylint: disable=unused-argument
|
||||
@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_data, # noqa: F401
|
||||
)
|
||||
from tests.integration_tests.fixtures.client import client # noqa: F401
|
||||
from tests.integration_tests.fixtures.energy_dashboard import (
|
||||
load_energy_table_data, # noqa: F401
|
||||
load_energy_table_with_slice, # noqa: F401
|
||||
@@ -59,6 +60,10 @@ from tests.integration_tests.fixtures.importexport import (
|
||||
database_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 (
|
||||
create_custom_tags, # noqa: F401
|
||||
get_filter_params,
|
||||
@@ -2444,3 +2449,30 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
|
||||
self.login(ADMIN_USERNAME)
|
||||
rv = self.client.get("api/v1/chart/related/owners")
|
||||
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_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 (
|
||||
load_world_bank_dashboard_with_slices, # noqa: F401
|
||||
load_world_bank_data, # noqa: F401
|
||||
@@ -4049,6 +4054,33 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
|
||||
db.session.delete(dashboard)
|
||||
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):
|
||||
"""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_data, # noqa: F401
|
||||
)
|
||||
from tests.integration_tests.fixtures.client import client # noqa: F401
|
||||
from tests.integration_tests.fixtures.energy_dashboard import (
|
||||
load_energy_table_data, # noqa: F401
|
||||
load_energy_table_with_slice, # noqa: F401
|
||||
@@ -64,6 +65,10 @@ from tests.integration_tests.fixtures.importexport import (
|
||||
dataset_config,
|
||||
dataset_ui_export,
|
||||
)
|
||||
from tests.integration_tests.fixtures.lineage import (
|
||||
inject_expected_dataset_lineage, # noqa: F401
|
||||
lineage_test_data, # noqa: F401
|
||||
)
|
||||
|
||||
|
||||
class TestDatasetApi(SupersetTestCase):
|
||||
@@ -3555,3 +3560,30 @@ class TestDatasetApi(SupersetTestCase):
|
||||
assert rv.status_code == 404
|
||||
|
||||
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