You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/10/26 18:51:20 UTC

(superset) 01/02: feat(lineage): add lineage visualization across datasets, charts and dashboards

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch adopt/lineage-visualization
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 3d58d806d27aa911e3d0584579c4bb2ca513cd78
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Fri Oct 23 22:48:44 2026 -0700

    feat(lineage): add lineage visualization across datasets, charts and dashboards
    
    Squashed and rebased onto current master.
    
    Co-Authored-By: Evan Rusackas <ev...@preset.io>
    Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
 docs/static/resources/openapi.json                 | 570 ++++++++++++++++
 .../DatasourceEditor/DatasourceEditor.tsx          |  27 +
 .../Header/useHeaderActionsDropdownMenu.tsx        |  18 +
 superset-frontend/src/dashboard/types.ts           |   1 +
 .../useExploreAdditionalActionsMenu/index.tsx      |  19 +
 .../src/features/charts/ChartCard.tsx              |  25 +
 .../src/features/dashboards/DashboardCard.tsx      |  19 +
 .../datasets/AddDataset/EditDataset/index.tsx      |  28 +-
 .../lineage/LazyLineageView.tsx}                   |  31 +-
 .../src/features/lineage/LineageModal.tsx          |  90 +++
 .../src/features/lineage/LineageView.tsx           | 743 +++++++++++++++++++++
 .../apiResources => features/lineage}/index.ts     |  17 +-
 .../src/hooks/apiResources/apiResources.test.ts    |  42 ++
 .../src/hooks/apiResources/apiResources.ts         |  16 +-
 superset-frontend/src/hooks/apiResources/index.ts  |   1 +
 .../src/hooks/apiResources/lineage.test.ts         | 128 ++++
 .../src/hooks/apiResources/lineage.ts              | 151 +++++
 superset/charts/api.py                             | 109 +++
 superset/charts/schemas.py                         |  48 ++
 superset/constants.py                              |   1 +
 superset/daos/dataset.py                           |   8 +-
 superset/dashboards/api.py                         | 124 ++++
 superset/dashboards/schemas.py                     |  59 ++
 superset/datasets/api.py                           | 127 ++++
 superset/datasets/schemas.py                       |  54 ++
 superset/translations/messages.pot                 |  61 ++
 tests/integration_tests/charts/api_tests.py        |  32 +
 tests/integration_tests/dashboards/api_tests.py    |  32 +
 tests/integration_tests/datasets/api_tests.py      |  36 +-
 tests/integration_tests/fixtures/lineage.py        | 266 ++++++++
 30 files changed, 2846 insertions(+), 37 deletions(-)

diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index 643f30c4932..4492b4f6910 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -2976,6 +2976,131 @@
         },
         "type": "object"
       },
+      "ChartLineageChart": {
+        "properties": {
+          "id": {
+            "type": "integer"
+          },
+          "slice_name": {
+            "type": "string"
+          },
+          "viz_type": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageDashboard": {
+        "properties": {
+          "id": {
+            "type": "integer"
+          },
+          "slug": {
+            "nullable": true,
+            "type": "string"
+          },
+          "title": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageDatabase": {
+        "properties": {
+          "backend": {
+            "type": "string"
+          },
+          "database_name": {
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageDataset": {
+        "properties": {
+          "database_id": {
+            "type": "integer"
+          },
+          "database_name": {
+            "nullable": true,
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "name": {
+            "type": "string"
+          },
+          "schema": {
+            "nullable": true,
+            "type": "string"
+          },
+          "table_name": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageDownstream": {
+        "properties": {
+          "dashboards": {
+            "$ref": "#/components/schemas/ChartLineageDownstreamDashboards"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageDownstreamDashboards": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/ChartLineageDashboard"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageResponseSchema": {
+        "properties": {
+          "chart": {
+            "$ref": "#/components/schemas/ChartLineageChart"
+          },
+          "downstream": {
+            "$ref": "#/components/schemas/ChartLineageDownstream"
+          },
+          "upstream": {
+            "$ref": "#/components/schemas/ChartLineageUpstream"
+          }
+        },
+        "type": "object"
+      },
+      "ChartLineageUpstream": {
+        "properties": {
+          "database": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/ChartLineageDatabase"
+              }
+            ],
+            "nullable": true
+          },
+          "dataset": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/ChartLineageDataset"
+              }
+            ],
+            "nullable": true
+          }
+        },
+        "type": "object"
+      },
       "ChartRestApi.get": {
         "properties": {
           "id": {
@@ -4211,6 +4336,158 @@
         },
         "type": "object"
       },
+      "DashboardLineageChart": {
+        "properties": {
+          "dataset_id": {
+            "type": "integer"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "slice_name": {
+            "type": "string"
+          },
+          "viz_type": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageDashboard": {
+        "properties": {
+          "id": {
+            "type": "integer"
+          },
+          "published": {
+            "type": "boolean"
+          },
+          "slug": {
+            "nullable": true,
+            "type": "string"
+          },
+          "title": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageDatabase": {
+        "properties": {
+          "backend": {
+            "type": "string"
+          },
+          "database_name": {
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageDataset": {
+        "properties": {
+          "chart_ids": {
+            "items": {
+              "type": "integer"
+            },
+            "type": "array"
+          },
+          "database_id": {
+            "nullable": true,
+            "type": "integer"
+          },
+          "database_name": {
+            "nullable": true,
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "name": {
+            "type": "string"
+          },
+          "schema": {
+            "nullable": true,
+            "type": "string"
+          },
+          "table_name": {
+            "nullable": true,
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageResponseSchema": {
+        "properties": {
+          "dashboard": {
+            "$ref": "#/components/schemas/DashboardLineageDashboard"
+          },
+          "downstream": {
+            "nullable": true
+          },
+          "upstream": {
+            "$ref": "#/components/schemas/DashboardLineageUpstream"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageUpstream": {
+        "properties": {
+          "charts": {
+            "$ref": "#/components/schemas/DashboardLineageUpstreamCharts"
+          },
+          "databases": {
+            "$ref": "#/components/schemas/DashboardLineageUpstreamDatabases"
+          },
+          "datasets": {
+            "$ref": "#/components/schemas/DashboardLineageUpstreamDatasets"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageUpstreamCharts": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/DashboardLineageChart"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageUpstreamDatabases": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/DashboardLineageDatabase"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
+      "DashboardLineageUpstreamDatasets": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/DashboardLineageDataset"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
       "DashboardNativeFiltersConfigUpdateSchema": {
         "properties": {
           "deleted": {
@@ -5998,6 +6275,152 @@
         ],
         "type": "object"
       },
+      "DatasetLineageChart": {
+        "properties": {
+          "dashboard_ids": {
+            "items": {
+              "type": "integer"
+            },
+            "type": "array"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "slice_name": {
+            "type": "string"
+          },
+          "viz_type": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDashboard": {
+        "properties": {
+          "chart_ids": {
+            "items": {
+              "type": "integer"
+            },
+            "type": "array"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "slug": {
+            "nullable": true,
+            "type": "string"
+          },
+          "title": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDatabase": {
+        "properties": {
+          "backend": {
+            "type": "string"
+          },
+          "database_name": {
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDataset": {
+        "properties": {
+          "database_id": {
+            "type": "integer"
+          },
+          "database_name": {
+            "nullable": true,
+            "type": "string"
+          },
+          "id": {
+            "type": "integer"
+          },
+          "name": {
+            "type": "string"
+          },
+          "schema": {
+            "nullable": true,
+            "type": "string"
+          },
+          "table_name": {
+            "type": "string"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDownstream": {
+        "properties": {
+          "charts": {
+            "$ref": "#/components/schemas/DatasetLineageDownstreamCharts"
+          },
+          "dashboards": {
+            "$ref": "#/components/schemas/DatasetLineageDownstreamDashboards"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDownstreamCharts": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/DatasetLineageChart"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageDownstreamDashboards": {
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "result": {
+            "items": {
+              "$ref": "#/components/schemas/DatasetLineageDashboard"
+            },
+            "type": "array"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageResponseSchema": {
+        "properties": {
+          "dataset": {
+            "$ref": "#/components/schemas/DatasetLineageDataset"
+          },
+          "downstream": {
+            "$ref": "#/components/schemas/DatasetLineageDownstream"
+          },
+          "upstream": {
+            "$ref": "#/components/schemas/DatasetLineageUpstream"
+          }
+        },
+        "type": "object"
+      },
+      "DatasetLineageUpstream": {
+        "properties": {
+          "database": {
+            "allOf": [
+              {
+                "$ref": "#/components/schemas/DatasetLineageDatabase"
+              }
+            ],
+            "nullable": true
+          }
+        },
+        "type": "object"
+      },
       "DatasetMetricCurrencyPut": {
         "properties": {
           "symbol": {
@@ -16807,6 +17230,55 @@
         ]
       }
     },
+    "/api/v1/chart/{id_or_uuid}/lineage": {
+      "get": {
+        "description": "Returns upstream (dataset, database) and downstream (dashboards) lineage information for a chart",
+        "parameters": [
+          {
+            "description": "Either the id of the chart, or its uuid",
+            "in": "path",
+            "name": "id_or_uuid",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ChartLineageResponseSchema"
+                }
+              }
+            },
+            "description": "Lineage information"
+          },
+          "401": {
+            "$ref": "#/components/responses/401"
+          },
+          "404": {
+            "$ref": "#/components/responses/404"
+          },
+          "500": {
+            "$ref": "#/components/responses/500"
+          }
+        },
+        "security": [
+          {
+            "jwt": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "summary": "Get lineage information for a chart",
+        "tags": [
+          "Charts"
+        ]
+      }
+    },
     "/api/v1/chart/{pk}": {
       "delete": {
         "parameters": [
@@ -19624,6 +20096,55 @@
         ]
       }
     },
+    "/api/v1/dashboard/{id_or_slug}/lineage": {
+      "get": {
+        "description": "Returns upstream (charts, datasets, databases) lineage information for a dashboard",
+        "parameters": [
+          {
+            "description": "Either the id of the dashboard, or its slug",
+            "in": "path",
+            "name": "id_or_slug",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/DashboardLineageResponseSchema"
+                }
+              }
+            },
+            "description": "Lineage information"
+          },
+          "401": {
+            "$ref": "#/components/responses/401"
+          },
+          "404": {
+            "$ref": "#/components/responses/404"
+          },
+          "500": {
+            "$ref": "#/components/responses/500"
+          }
+        },
+        "security": [
+          {
+            "jwt": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "summary": "Get lineage information for a dashboard",
+        "tags": [
+          "Dashboards"
+        ]
+      }
+    },
     "/api/v1/dashboard/{id_or_slug}/tabs": {
       "get": {
         "description": "Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs.",
@@ -24478,6 +24999,55 @@
         ]
       }
     },
+    "/api/v1/dataset/{id_or_uuid}/lineage": {
+      "get": {
+        "description": "Returns upstream (database) and downstream (charts, dashboards) lineage information for a dataset",
+        "parameters": [
+          {
+            "description": "Either the id of the dataset, or its uuid",
+            "in": "path",
+            "name": "id_or_uuid",
+            "required": true,
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/DatasetLineageResponseSchema"
+                }
+              }
+            },
+            "description": "Lineage information"
+          },
+          "401": {
+            "$ref": "#/components/responses/401"
+          },
+          "404": {
+            "$ref": "#/components/responses/404"
+          },
+          "500": {
+            "$ref": "#/components/responses/500"
+          }
+        },
+        "security": [
+          {
+            "jwt": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "summary": "Get lineage information for a dataset",
+        "tags": [
+          "Datasets"
+        ]
+      }
+    },
     "/api/v1/dataset/{id_or_uuid}/related_objects": {
       "get": {
         "parameters": [
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index 34e88c1b14b..e5e9ab7ec51 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -63,6 +63,7 @@ import {
   Col,
   Divider,
   EditableTitle,
+  Empty,
   Flex,
   FormLabel,
   Icons,
@@ -93,6 +94,8 @@ import { DatabaseSelector } from '../../../DatabaseSelector';
 import SpatialControl from 'src/explore/components/controls/SpatialControl';
 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';
@@ -475,6 +478,20 @@ 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 (
+      <Empty
+        description={t('Lineage is available after the dataset is saved')}
+      />
+    );
+  }
+  return <LineageView lineageResource={lineageResource} entityType="dataset" />;
+}
+
 const DefaultColumnSettingsContainer = styled.div`
   ${({ theme }) => css`
     margin-bottom: ${theme.sizeUnit * 4}px;
@@ -534,6 +551,7 @@ const TABS_KEYS = {
   COLUMNS: 'COLUMNS',
   CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
   USAGE: 'USAGE',
+  LINEAGE: 'LINEAGE',
   FOLDERS: 'FOLDERS',
   SETTINGS: 'SETTINGS',
   SPATIAL: 'SPATIAL',
@@ -2730,6 +2748,15 @@ function DatasourceEditor({
           </StyledTableTabWrapper>
         ),
       },
+      {
+        key: TABS_KEYS.LINEAGE,
+        label: t('Lineage'),
+        children: (
+          <StyledTableTabWrapper>
+            <DatasetLineageTab datasourceId={datasource.id} />
+          </StyledTableTabWrapper>
+        ),
+      },
       ...(isFeatureEnabled(FeatureFlag.DatasetFolders)
         ? [
             {
diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index a2812f6d056..5cf9b91f98e 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -41,6 +41,7 @@ import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
 import { usePermissions } from 'src/hooks/usePermissions';
 import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
 import getUserName from 'src/utils/getUserName';
+import { LineageModal } from 'src/features/lineage';
 
 export const useHeaderActionsMenu = ({
   customCss,
@@ -306,6 +307,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({
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 37cab5f5c84..ce0e9db59f3 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -423,4 +423,5 @@ export enum MenuKeys {
   ExportPivotXlsx = 'export_pivot_xlsx',
   EmbedCode = 'embed_code',
   VersionHistory = 'version_history',
+  ViewLineage = 'view_lineage',
 }
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 56ece0349fd..efc0921a464 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -75,6 +75,7 @@ import ViewQueryModal from '../controls/ViewQueryModal';
 import EmbedCodeContent from '../EmbedCodeContent';
 import { useDashboardsMenuItems } from './DashboardsSubMenu';
 import { useExploreDataExport } from './useExploreDataExport';
+import { LineageModal } from 'src/features/lineage';
 
 export const SEARCH_THRESHOLD = 10;
 
@@ -150,6 +151,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',
   VERSION_HISTORY: 'version_history',
 };
@@ -1151,6 +1153,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({
diff --git a/superset-frontend/src/features/charts/ChartCard.tsx b/superset-frontend/src/features/charts/ChartCard.tsx
index 654914b71b4..4ad71f1cce6 100644
--- a/superset-frontend/src/features/charts/ChartCard.tsx
+++ b/superset-frontend/src/features/charts/ChartCard.tsx
@@ -46,6 +46,7 @@ import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components'
 import { TableTab } from 'src/views/CRUD/types';
 import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
 import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { LineageModal } from 'src/features/lineage';
 
 const menuItemButtonCss = css`
   appearance: none;
@@ -105,6 +106,7 @@ export default function ChartCard({
     chart.editors,
     chart.extra_editors,
   );
+  const canRead = hasPerm('can_read');
   const menuItems: MenuItem[] = [];
 
   if (canEdit) {
@@ -140,6 +142,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',
diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx
index 08ffc7cbbc4..264f6ffda20 100644
--- a/superset-frontend/src/features/dashboards/DashboardCard.tsx
+++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx
@@ -37,6 +37,7 @@ import { KebabMenuButton } from 'src/components';
 import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
 import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
 import { useIsMobile } from 'src/hooks/useIsMobile';
+import { LineageModal } from 'src/features/lineage';
 
 const menuItemButtonCss = css`
   appearance: none;
@@ -90,6 +91,7 @@ function DashboardCard({
     dashboard.editors,
     dashboard.extra_editors,
   );
+  const canRead = hasPerm('can_read');
   const digest = dashboard.changed_on_utc || dashboard.changed_on;
   const thumbnailUrl =
     isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
@@ -98,6 +100,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',
diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
index e0438cee0bd..d816b2b1450 100644
--- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
+++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
@@ -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;
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/features/lineage/LazyLineageView.tsx
similarity index 54%
copy from superset-frontend/src/hooks/apiResources/index.ts
copy to superset-frontend/src/features/lineage/LazyLineageView.tsx
index 53aa7aa113c..e1ea2f00322 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/features/lineage/LazyLineageView.tsx
@@ -16,19 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-export { skipToken } from '@reduxjs/toolkit/query/react';
-export {
-  useApiResourceFullBody,
-  useApiV1Resource,
-  useTransformedResource,
-} from './apiResources';
+import { FC, Suspense, lazy } from 'react';
+import { Loading } from '@superset-ui/core/components';
+import type { LineageViewProps } from './LineageView';
 
-// A central catalog of API Resource hooks.
-// Add new API hooks here, organized under
-// different files for different resource types.
-export * from './catalogs';
-export * from './charts';
-export * from './dashboards';
-export * from './tables';
-export * from './schemas';
-export * from './queryValidations';
+// The lineage view pulls in the ECharts renderer and the Sankey series, which
+// only a small fraction of page loads ever open. Loading it on demand keeps
+// that weight out of the SPA entrypoint for the list, explore and dashboard
+// pages that merely render a "Lineage" menu item.
+const LineageViewChunk = lazy(() => import('./LineageView'));
+
+const LazyLineageView: FC<LineageViewProps> = props => (
+  <Suspense fallback={<Loading />}>
+    <LineageViewChunk {...props} />
+  </Suspense>
+);
+
+export default LazyLineageView;
diff --git a/superset-frontend/src/features/lineage/LineageModal.tsx b/superset-frontend/src/features/lineage/LineageModal.tsx
new file mode 100644
index 00000000000..53b1683a65b
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageModal.tsx
@@ -0,0 +1,90 @@
+/**
+ * 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, useState } 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 './LazyLineageView';
+
+export interface LineageModalProps {
+  entityType: 'dataset' | 'chart' | 'dashboard';
+  entityId: string | number;
+  triggerNode: ReactNode;
+}
+
+const LineageModal: FC<LineageModalProps> = ({
+  entityType,
+  entityId,
+  triggerNode,
+}) => {
+  // Defer the lineage fetch until the modal is actually opened so that simply
+  // rendering the trigger (e.g. inside an actions dropdown) does not hit the
+  // lineage endpoint.
+  const [opened, setOpened] = useState(false);
+
+  const datasetLineage = useDatasetLineage(
+    entityType === 'dataset' ? entityId : '',
+    !opened,
+  );
+  const chartLineage = useChartLineage(
+    entityType === 'chart' ? entityId : '',
+    !opened,
+  );
+  const dashboardLineage = useDashboardLineage(
+    entityType === 'dashboard' ? entityId : '',
+    !opened,
+  );
+
+  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}
+      beforeOpen={() => setOpened(true)}
+      modalTitle={title}
+      modalBody={
+        <LineageView
+          lineageResource={lineageResource}
+          entityType={entityType}
+        />
+      }
+      width="850px"
+      responsive
+      destroyOnHidden
+    />
+  );
+};
+
+export default LineageModal;
diff --git a/superset-frontend/src/features/lineage/LineageView.tsx b/superset-frontend/src/features/lineage/LineageView.tsx
new file mode 100644
index 00000000000..fb9f5ec3e52
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageView.tsx
@@ -0,0 +1,743 @@
+/**
+ * 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 { useResizeDetector } from 'react-resize-detector';
+import { t } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Button, Empty, Loading } from '@superset-ui/core/components';
+import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
+import type { Resource } from 'src/hooks/apiResources/apiResources';
+import { redirect } from 'src/utils/navigationUtils';
+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 ?? ''}`;
+
+export 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);
+  const { width: chartWidth = 800, ref: chartContainerRef } =
+    useResizeDetector();
+
+  // 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
+          let linked = false;
+          if (dashboard.chart_ids && dashboard.chart_ids.length > 0) {
+            dashboard.chart_ids.forEach(chartId => {
+              const chartKey = chartKeys.get(chartId);
+              if (chartKey) {
+                addLink(chartKey, dashKey);
+                linked = true;
+              }
+            });
+          }
+          // Fall back to a direct dataset -> dashboard edge so the dashboard
+          // still renders when none of its charts resolve to visible nodes
+          // (e.g. after permission filtering); Sankey omits orphaned nodes.
+          if (!linked) {
+            addLink(datasetKey, 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: t('Database (Upstream)'), color: theme.colorInfo },
+        { label: t('Dataset (Current)'), color: theme.colorPrimary },
+        { label: t('Chart (Downstream)'), color: theme.colorSuccess },
+        { label: t('Dashboard (Downstream)'), color: theme.colorWarning },
+      ];
+    } else if (entityType === 'chart') {
+      return [
+        { label: t('Database (Upstream)'), color: theme.colorWarning },
+        { label: t('Dataset (Upstream)'), color: theme.colorInfo },
+        { label: t('Chart (Current)'), color: theme.colorPrimary },
+        { label: t('Dashboard (Downstream)'), color: theme.colorSuccess },
+      ];
+    } else if (entityType === 'dashboard') {
+      return [
+        { label: t('Database (Upstream)'), color: theme.colorWarning },
+        { label: t('Dataset (Upstream)'), color: theme.colorSuccess },
+        { label: t('Chart (Upstream)'), color: theme.colorInfo },
+        { label: t('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. Datasets have no standalone
+  // detail page, so only dashboards and charts expose an "Open" action.
+  const getEntityUrl = (nodeDetails: NodeDetails): string => {
+    switch (nodeDetails.type) {
+      case 'dashboard':
+        return `/dashboard/${nodeDetails.id}/`;
+      case 'chart':
+        return `/explore/?slice_id=${nodeDetails.id}`;
+      default:
+        return '#';
+    }
+  };
+
+  return (
+    <LineageContainer>
+      <Legend>
+        {legendItems.map(item => (
+          <LegendItem key={item.label} color={item.color}>
+            {item.label}
+          </LegendItem>
+        ))}
+      </Legend>
+      <div ref={chartContainerRef} style={{ width: '100%' }}>
+        <Echart
+          refs={{}}
+          height={selectedNode ? 450 : 600}
+          width={chartWidth}
+          echartOptions={echartOptions}
+          vizType="sankey"
+          eventHandlers={{
+            click: handleNodeClick,
+          }}
+        />
+      </div>
+      {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={() => {
+                    redirect(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;
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/features/lineage/index.ts
similarity index 63%
copy from superset-frontend/src/hooks/apiResources/index.ts
copy to superset-frontend/src/features/lineage/index.ts
index 53aa7aa113c..8c77ba35f03 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/features/lineage/index.ts
@@ -16,19 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-export { skipToken } from '@reduxjs/toolkit/query/react';
-export {
-  useApiResourceFullBody,
-  useApiV1Resource,
-  useTransformedResource,
-} from './apiResources';
 
-// A central catalog of API Resource hooks.
-// Add new API hooks here, organized under
-// different files for different resource types.
-export * from './catalogs';
-export * from './charts';
-export * from './dashboards';
-export * from './tables';
-export * from './schemas';
-export * from './queryValidations';
+export { default as LineageView } from './LazyLineageView';
+export { default as LineageModal } from './LineageModal';
diff --git a/superset-frontend/src/hooks/apiResources/apiResources.test.ts b/superset-frontend/src/hooks/apiResources/apiResources.test.ts
index 9a66838638c..727e31f19b0 100644
--- a/superset-frontend/src/hooks/apiResources/apiResources.test.ts
+++ b/superset-frontend/src/hooks/apiResources/apiResources.test.ts
@@ -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 jest.Mock).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 jest.Mock).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
diff --git a/superset-frontend/src/hooks/apiResources/apiResources.ts b/superset-frontend/src/hooks/apiResources/apiResources.ts
index 04166db1d32..ec6894128d4 100644
--- a/superset-frontend/src/hooks/apiResources/apiResources.ts
+++ b/superset-frontend/src/hooks/apiResources/apiResources.ts
@@ -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,
   );
 }
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts
index 53aa7aa113c..61d3628a61b 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/hooks/apiResources/index.ts
@@ -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';
diff --git a/superset-frontend/src/hooks/apiResources/lineage.test.ts b/superset-frontend/src/hooks/apiResources/lineage.test.ts
new file mode 100644
index 00000000000..38072dcc8ea
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/lineage.test.ts
@@ -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();
+});
diff --git a/superset-frontend/src/hooks/apiResources/lineage.ts b/superset-frontend/src/hooks/apiResources/lineage.ts
new file mode 100644
index 00000000000..56e7a7a6a80
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/lineage.ts
@@ -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),
+  );
diff --git a/superset/charts/api.py b/superset/charts/api.py
index fd83e07cc93..6447bc7cb73 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -261,6 +261,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
         "get_version",
         "activity",
         "restore_version",
+        "lineage",
     }
     class_permission_name = "Chart"
     # Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -691,6 +692,114 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
         ]
         return self.response(200, result=result)
 
+    @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. 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), mirroring the dashboard lineage endpoint.
+        upstream: dict[str, Any] = {}
+        if dataset := chart.datasource:
+            can_access = security_manager.can_access_datasource(dataset)
+            upstream["dataset"] = {
+                "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,
+            }
+            if can_access and 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: list[dict[str, Any]] = []
+        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
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index a4c90628b75..975e47b8083 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -2139,6 +2139,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(allow_none=True)
+    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(allow_none=True)
+
+
+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,
@@ -2166,4 +2213,5 @@ CHART_SCHEMAS = (
     ChartGetResponseSchema,
     ChartCacheScreenshotResponseSchema,
     GetFavStarIdsSchema,
+    ChartLineageResponseSchema,
 )
diff --git a/superset/constants.py b/superset/constants.py
index 818683352df..aace2d5a8c6 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -204,6 +204,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
     "list_versions": "read",
     "get_version": "read",
     "activity": "read",
+    "lineage": "read",
 }
 
 EXTRA_FORM_DATA_APPEND_KEYS = {
diff --git a/superset/daos/dataset.py b/superset/daos/dataset.py
index f571bec5bca..6d798bb25a5 100644
--- a/superset/daos/dataset.py
+++ b/superset/daos/dataset.py
@@ -23,7 +23,7 @@ from typing import Any, Dict, List
 import dateutil.parser
 from sqlalchemy import or_, select
 from sqlalchemy.exc import SQLAlchemyError
-from sqlalchemy.orm import joinedload, Query
+from sqlalchemy.orm import joinedload, Query, selectinload
 
 from superset.connectors.sqla.models import (
     RLSFilterTables,
@@ -161,6 +161,9 @@ class DatasetDAO(BaseDAO[SqlaTable]):
                 Slice.datasource_id.in_(dataset_ids),
                 Slice.datasource_type == DatasourceType.TABLE,
             )
+            # Eager-load the related dashboards so callers (e.g. lineage) can
+            # iterate ``chart.dashboards`` without triggering a query per chart.
+            .options(selectinload(Slice.dashboards))
             .all()
         )
         chart_ids = [chart.id for chart in charts]
@@ -170,6 +173,9 @@ class DatasetDAO(BaseDAO[SqlaTable]):
                 db.session.query(Dashboard)
                 .join(Dashboard.slices)
                 .filter(Slice.id.in_(chart_ids))
+                # Eager-load slices so callers can iterate ``dashboard.slices``
+                # without a query per dashboard.
+                .options(selectinload(Dashboard.slices))
             )
             .distinct()
             .all()
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index e2335c2d868..473947d7884 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -158,6 +158,7 @@ from superset.dashboards.schemas import (
     DashboardExportXlsxPostSchema,
     DashboardExportXlsxResponseSchema,
     DashboardGetResponseSchema,
+    DashboardLineageResponseSchema,
     DashboardNativeFiltersConfigUpdateSchema,
     DashboardPostSchema,
     DashboardPutSchema,
@@ -456,6 +457,7 @@ class DashboardRestApi(
         "get_version",
         "activity",
         "restore_version",
+        "lineage",
     }
     resource_name = "dashboard"
     allow_browser_login = True
@@ -698,9 +700,11 @@ class DashboardRestApi(
         DashboardCacheScreenshotResponseSchema,
         DashboardCopySchema,
         DashboardGetResponseSchema,
+        DashboardLineageResponseSchema,
         DashboardDatasetSchema,
         DashboardExportXlsxPostSchema,
         DashboardExportXlsxResponseSchema,
+        DashboardLineageResponseSchema,
         TabsPayloadSchema,
         GetFavStarIdsSchema,
         EmbeddedDashboardResponseSchema,
@@ -820,6 +824,126 @@ class DashboardRestApi(
             current_entity_etag_uuid(Dashboard, dash.id, dash.uuid),
         )
 
+    @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: list[dict[str, Any]] = []
+        dataset_map: dict[int, dict[str, Any]] = {}
+        database_map: dict[int, dict[str, Any]] = {}
+
+        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
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index 409240d2eae..c241bb72713 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -721,3 +721,62 @@ class DashboardExportXlsxResponseSchema(Schema):
     job_id = fields.String(
         metadata={"description": "Correlation id for the async export task"}
     )
+
+
+class DashboardLineageDashboardSchema(Schema):
+    id = fields.Integer()
+    title = fields.String()
+    slug = fields.String(allow_none=True)
+    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/schema/table details are redacted to ``None`` when the user
+    # cannot access the underlying datasource, so they must be nullable.
+    database_id = fields.Integer(allow_none=True)
+    database_name = fields.String(allow_none=True)
+    schema = fields.String(allow_none=True)
+    table_name = fields.String(allow_none=True)
+    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)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index 4fb8c333ca4..65d24fd2a73 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -90,6 +90,7 @@ from superset.datasets.schemas import (
     DatasetCacheWarmUpResponseSchema,
     DatasetDrillInfoSchema,
     DatasetDuplicateSchema,
+    DatasetLineageResponseSchema,
     DatasetPostSchema,
     DatasetPurgeImpactSchema,
     DatasetPurgeRequestSchema,
@@ -217,6 +218,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
         "get_version",
         "activity",
         "restore_version",
+        "lineage",
     }
     list_columns = [
         "id",
@@ -450,6 +452,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
         DatasetDuplicateSchema,
         GetOrCreateDatasetSchema,
         VersionListItemSchema,
+        DatasetLineageResponseSchema,
     )
 
     openapi_spec_methods = openapi_spec_methods_override
@@ -1380,6 +1383,130 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
             },
         }
 
+    @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: dict[str, Any] = {
+            "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: dict[str, Any] = 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: list[dict[str, Any]] = []
+        for chart in related_data["charts"]:
+            if not security_manager.can_access_chart(chart):
+                continue
+            dashboard_ids: list[int] = [
+                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: list[dict[str, Any]] = []
+        for dashboard in related_data["dashboards"]:
+            if not security_manager.can_access_dashboard(dashboard):
+                continue
+            chart_ids: list[int] = [
+                chart.id
+                for chart in dashboard.slices
+                if chart.datasource_id == dataset.id
+                and security_manager.can_access_chart(chart)
+            ]
+            dashboards.append(
+                {
+                    "id": dashboard.id,
+                    "title": dashboard.dashboard_title,
+                    "slug": dashboard.slug,
+                    "chart_ids": chart_ids,
+                }
+            )
+
+        downstream: dict[str, Any] = {
+            "charts": {
+                "count": len(charts),
+                "result": charts,
+            },
+            "dashboards": {
+                "count": len(dashboards),
+                "result": dashboards,
+            },
+        }
+
+        result: dict[str, Any] = {
+            "dataset": dataset_info,
+            "upstream": upstream,
+            "downstream": downstream,
+        }
+        return self.response(200, result=result)
+
     @expose("/", methods=("DELETE",))
     @protect()
     @safe
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 163776b818a..b271719433c 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -366,6 +366,60 @@ class DatasetPurgeImpactSchema(Schema):
     )
 
 
+class DatasetLineageDatasetSchema(Schema):
+    id = fields.Integer()
+    name = fields.String()
+    database_id = fields.Integer()
+    database_name = fields.String(allow_none=True)
+    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(allow_none=True)
+    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
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index 3197a2c18b9..3bbe4f12f54 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -279,6 +279,10 @@ msgstr ""
 msgid "%s (copy from %s)"
 msgstr ""
 
+#, python-format
+msgid "%s Details"
+msgstr ""
+
 #, python-format
 msgid "%s ENCRYPTED EXTRA"
 msgstr ""
@@ -3105,6 +3109,15 @@ msgstr ""
 msgid "Chart %(id)s not found"
 msgstr ""
 
+msgid "Chart (Current)"
+msgstr ""
+
+msgid "Chart (Downstream)"
+msgstr ""
+
+msgid "Chart (Upstream)"
+msgstr ""
+
 #, python-format
 msgid "Chart Data: %s"
 msgstr ""
@@ -3118,6 +3131,9 @@ msgstr[1] ""
 msgid "Chart ID"
 msgstr ""
 
+msgid "Chart Lineage"
+msgstr ""
+
 msgid "Chart Options"
 msgstr ""
 
@@ -4529,12 +4545,21 @@ msgstr ""
 msgid "Dashboard %(dashboard_id)s not found"
 msgstr ""
 
+msgid "Dashboard (Current)"
+msgstr ""
+
+msgid "Dashboard (Downstream)"
+msgstr ""
+
 msgid "Dashboard Filter"
 msgstr ""
 
 msgid "Dashboard Id"
 msgstr ""
 
+msgid "Dashboard Lineage"
+msgstr ""
+
 #, python-format
 msgid "Dashboard [%s] just got created and chart [%s] was added to it"
 msgstr ""
@@ -4702,6 +4727,9 @@ msgstr ""
 msgid "Database"
 msgstr ""
 
+msgid "Database (Upstream)"
+msgstr ""
+
 msgid "Database Connections"
 msgstr ""
 
@@ -4787,6 +4815,15 @@ msgstr ""
 msgid "Dataset %(table)s already exists"
 msgstr ""
 
+msgid "Dataset (Current)"
+msgstr ""
+
+msgid "Dataset (Upstream)"
+msgstr ""
+
+msgid "Dataset Lineage"
+msgstr ""
+
 msgid "Dataset Name"
 msgstr ""
 
@@ -6671,6 +6708,9 @@ msgstr ""
 msgid "Failed to load drill-to-detail rows"
 msgstr ""
 
+msgid "Failed to load lineage data"
+msgstr ""
+
 msgid "Failed to load results"
 msgstr ""
 
@@ -7478,6 +7518,9 @@ msgstr ""
 msgid "Huge"
 msgstr ""
 
+msgid "ID"
+msgstr ""
+
 msgid "ISO 3166-2"
 msgstr ""
 
@@ -8413,6 +8456,12 @@ msgstr ""
 msgid "Line width unit"
 msgstr ""
 
+msgid "Lineage"
+msgstr ""
+
+msgid "Lineage is available after the dataset is saved"
+msgstr ""
+
 msgid "Linear"
 msgstr ""
 
@@ -9468,6 +9517,9 @@ msgstr ""
 msgid "No items"
 msgstr ""
 
+msgid "No lineage data available"
+msgstr ""
+
 msgid "No matching records found"
 msgstr ""
 
@@ -9911,6 +9963,9 @@ msgstr ""
 msgid "Opacity, expects values between 0 and 100"
 msgstr ""
 
+msgid "Open"
+msgstr ""
+
 msgid "Open Datasource tab"
 msgstr ""
 
@@ -16328,6 +16383,9 @@ msgstr ""
 msgid "View Dataset"
 msgstr ""
 
+msgid "View Lineage"
+msgstr ""
+
 msgid "View all charts"
 msgstr ""
 
@@ -16337,6 +16395,9 @@ msgstr ""
 msgid "View in SQL Lab"
 msgstr ""
 
+msgid "View lineage"
+msgstr ""
+
 msgid "View query"
 msgstr ""
 
diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py
index 3a7cedb8c28..1adc38e9b10 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -61,6 +61,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
@@ -70,6 +71,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,
@@ -2980,3 +2985,30 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
         self.login(ADMIN_USERNAME)
         rv = self.client.get("api/v1/chart/related/editors")
         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
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index c396af1d663..429ffe125d5 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -109,6 +109,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
@@ -8122,6 +8127,33 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
             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.
diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py
index 4844193cb29..9da7533e3fd 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -65,6 +65,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
@@ -74,6 +75,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
+)
 
 # Fields the dataset ``show`` payload exposes but the ``PUT`` schema doesn't
 # accept: audit timestamps plus attributes derived from the model (type
@@ -385,8 +390,8 @@ class TestDatasetApi(SupersetTestCase):
         """
         Dataset API: Test get dataset list with invalid JWT authentication
         """
-        client = self.create_app().test_client()
-        rv = client.get(
+        test_client = self.create_app().test_client()
+        rv = test_client.get(
             "api/v1/dataset/",
             headers={"Authorization": "Bearer not-a-token"},
         )
@@ -4023,6 +4028,33 @@ class TestDatasetApi(SupersetTestCase):
 
         self.items_to_delete = [dash, chart, dataset, dashboard_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
+
 
 class TestRequiresJsonReturns400(SupersetTestCase):
     """sc-120966: a body-less POST to a @safe + @requires_json endpoint
diff --git a/tests/integration_tests/fixtures/lineage.py b/tests/integration_tests/fixtures/lineage.py
new file mode 100644
index 00000000000..e4d606b3cb2
--- /dev/null
+++ b/tests/integration_tests/fixtures/lineage.py
@@ -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,
+        },
+    }