From 210645627db2b701fca86f70928a61a671192c33 Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Thu, 5 Feb 2026 00:16:24 -0500 Subject: [PATCH 1/6] feat: implement lineage endpoint for charts, dashboards, and datasets --- superset/charts/api.py | 98 ++++++++++++++++++++++++++++ superset/charts/schemas.py | 48 ++++++++++++++ superset/constants.py | 1 + superset/dashboards/api.py | 109 +++++++++++++++++++++++++++++++ superset/dashboards/schemas.py | 57 ++++++++++++++++ superset/datasets/api.py | 116 +++++++++++++++++++++++++++++++++ superset/datasets/schemas.py | 54 +++++++++++++++ 7 files changed, 483 insertions(+) diff --git a/superset/charts/api.py b/superset/charts/api.py index 66c0781ffb31..8a1c7250ab4b 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -130,6 +130,7 @@ def ensure_thumbnails_enabled(self) -> Optional[Response]: "screenshot", "cache_screenshot", "warm_up_cache", + "lineage", } class_permission_name = "Chart" method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP @@ -311,6 +312,103 @@ def get(self, id_or_uuid: str) -> Response: except ChartNotFoundError: return self.response_404() + @expose("//lineage", methods=("GET",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage", + log_to_statsd=False, + ) + def lineage(self, id_or_uuid: str) -> Response: + """Get lineage information for a chart. + --- + get: + summary: Get lineage information for a chart + description: >- + Returns upstream (dataset, database) and downstream (dashboards) lineage + information for a chart + parameters: + - in: path + name: id_or_uuid + schema: + type: string + description: Either the id of the chart, or its uuid + responses: + 200: + description: Lineage information + content: + application/json: + schema: + $ref: "#/components/schemas/ChartLineageResponseSchema" + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + chart = ChartDAO.get_by_id_or_uuid(id_or_uuid) + except ChartNotFoundError: + return self.response_404() + + chart_info = { + "id": chart.id, + "slice_name": chart.slice_name, + "viz_type": chart.viz_type, + } + + # Get upstream (dataset and database) information + upstream: dict[str, Any] = {} + if dataset := chart.datasource: + upstream["dataset"] = { + "id": dataset.id, + "name": dataset.name, + "database_id": dataset.database_id, + "database_name": dataset.database.database_name + if dataset.database + else None, + "schema": dataset.schema, + "table_name": dataset.table_name, + } + if dataset.database: + upstream["database"] = { + "id": dataset.database.id, + "database_name": dataset.database.database_name, + "backend": dataset.database.backend, + } + else: + upstream["database"] = None + else: + upstream["dataset"] = None + upstream["database"] = None + + # Get downstream (dashboards) information + dashboards = [] + for dashboard in chart.dashboards: + dashboards.append( + { + "id": dashboard.id, + "title": dashboard.dashboard_title, + "slug": dashboard.slug, + } + ) + + downstream = { + "dashboards": { + "count": len(dashboards), + "result": dashboards, + }, + } + + return self.response( + 200, + chart=chart_info, + upstream=upstream, + downstream=downstream, + ) + @expose("/", methods=("POST",)) @protect() @safe diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py index 8cc500ecac18..c358419423cd 100644 --- a/superset/charts/schemas.py +++ b/superset/charts/schemas.py @@ -1704,6 +1704,53 @@ class ChartGetResponseSchema(Schema): datasource_uuid = fields.UUID(attribute="table.uuid") +class ChartLineageChartSchema(Schema): + id = fields.Integer() + slice_name = fields.String() + viz_type = fields.String() + + +class ChartLineageDatasetSchema(Schema): + id = fields.Integer() + name = fields.String() + database_id = fields.Integer() + database_name = fields.String() + schema = fields.String(allow_none=True) + table_name = fields.String() + + +class ChartLineageDatabaseSchema(Schema): + id = fields.Integer() + database_name = fields.String() + backend = fields.String() + + +class ChartLineageDashboardSchema(Schema): + id = fields.Integer() + title = fields.String() + slug = fields.String() + + +class ChartLineageUpstreamSchema(Schema): + dataset = fields.Nested(ChartLineageDatasetSchema, allow_none=True) + database = fields.Nested(ChartLineageDatabaseSchema, allow_none=True) + + +class ChartLineageDownstreamDashboardsSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(ChartLineageDashboardSchema)) + + +class ChartLineageDownstreamSchema(Schema): + dashboards = fields.Nested(ChartLineageDownstreamDashboardsSchema) + + +class ChartLineageResponseSchema(Schema): + chart = fields.Nested(ChartLineageChartSchema) + upstream = fields.Nested(ChartLineageUpstreamSchema) + downstream = fields.Nested(ChartLineageDownstreamSchema) + + CHART_SCHEMAS = ( ChartCacheWarmUpRequestSchema, ChartCacheWarmUpResponseSchema, @@ -1730,4 +1777,5 @@ class ChartGetResponseSchema(Schema): ChartGetResponseSchema, ChartCacheScreenshotResponseSchema, GetFavStarIdsSchema, + ChartLineageResponseSchema, ) diff --git a/superset/constants.py b/superset/constants.py index d285fb1f9014..b83e3d5cd78e 100644 --- a/superset/constants.py +++ b/superset/constants.py @@ -174,6 +174,7 @@ class RouteMethod: # pylint: disable=too-few-public-methods "put_filters": "write", "put_colors": "write", "sync_permissions": "write", + "lineage": "read", } EXTRA_FORM_DATA_APPEND_KEYS = { diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index 4592138866e9..b0ac3724b0f2 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -244,6 +244,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi): "put_chart_customizations", "put_colors", "export_as_example", + "lineage", } resource_name = "dashboard" allow_browser_login = True @@ -491,6 +492,114 @@ def get( ) return self.response(200, result=result) + @expose("//lineage", methods=("GET",)) + @protect() + @safe + @statsd_metrics + @with_dashboard + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage", + log_to_statsd=False, + ) + # pylint: disable=arguments-differ,arguments-renamed + def lineage(self, dash: Dashboard) -> Response: + """Get lineage information for a dashboard. + --- + get: + summary: Get lineage information for a dashboard + description: >- + Returns upstream (charts, datasets, databases) lineage information + for a dashboard + parameters: + - in: path + name: id_or_slug + schema: + type: string + description: Either the id of the dashboard, or its slug + responses: + 200: + description: Lineage information + content: + application/json: + schema: + $ref: "#/components/schemas/DashboardLineageResponseSchema" + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + dashboard_info = { + "id": dash.id, + "title": dash.dashboard_title, + "slug": dash.slug, + "published": dash.published, + } + + # Get upstream (charts, datasets, databases) information + charts = [] + dataset_map = {} + database_map = {} + + for chart in dash.slices: + charts.append( + { + "id": chart.id, + "slice_name": chart.slice_name, + "viz_type": chart.viz_type, + "dataset_id": chart.datasource_id, + } + ) + + # Collect dataset information + dataset = chart.datasource + if dataset and dataset.id not in dataset_map: + dataset_map[dataset.id] = { + "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, + "chart_ids": [], + } + + if dataset and dataset.id in dataset_map: + dataset_map[dataset.id]["chart_ids"].append(chart.id) + + # Collect database information + if 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()), + }, + } + + return self.response( + 200, + dashboard=dashboard_info, + upstream=upstream, + downstream=None, + ) + @expose("//datasets", methods=("GET",)) @protect() @handle_api_exception diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py index 06f3efc7987d..0ae4f9642913 100644 --- a/superset/dashboards/schemas.py +++ b/superset/dashboards/schemas.py @@ -557,3 +557,60 @@ class CacheScreenshotSchema(Schema): fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False ) permalinkKey = fields.Str(required=False) # noqa: N815 + + +class DashboardLineageDashboardSchema(Schema): + id = fields.Integer() + title = fields.String() + slug = fields.String() + published = fields.Boolean() + + +class DashboardLineageChartSchema(Schema): + id = fields.Integer() + slice_name = fields.String() + viz_type = fields.String() + dataset_id = fields.Integer() + + +class DashboardLineageDatasetSchema(Schema): + id = fields.Integer() + name = fields.String() + database_id = fields.Integer() + database_name = fields.String() + schema = fields.String(allow_none=True) + table_name = fields.String() + chart_ids = fields.List(fields.Integer()) + + +class DashboardLineageDatabaseSchema(Schema): + id = fields.Integer() + database_name = fields.String() + backend = fields.String() + + +class DashboardLineageUpstreamChartsSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(DashboardLineageChartSchema)) + + +class DashboardLineageUpstreamDatasetsSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(DashboardLineageDatasetSchema)) + + +class DashboardLineageUpstreamDatabasesSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(DashboardLineageDatabaseSchema)) + + +class DashboardLineageUpstreamSchema(Schema): + charts = fields.Nested(DashboardLineageUpstreamChartsSchema) + datasets = fields.Nested(DashboardLineageUpstreamDatasetsSchema) + databases = fields.Nested(DashboardLineageUpstreamDatabasesSchema) + + +class DashboardLineageResponseSchema(Schema): + dashboard = fields.Nested(DashboardLineageDashboardSchema) + upstream = fields.Nested(DashboardLineageUpstreamSchema) + downstream = fields.Field(allow_none=True) diff --git a/superset/datasets/api.py b/superset/datasets/api.py index dbb51b9bf355..efd7e0f2466b 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -67,6 +67,7 @@ DatasetCacheWarmUpResponseSchema, DatasetDrillInfoSchema, DatasetDuplicateSchema, + DatasetLineageResponseSchema, DatasetPostSchema, DatasetPutSchema, DatasetRelatedObjectsResponse, @@ -117,6 +118,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): "get_or_create_dataset", "warm_up_cache", "get_drill_info", + "lineage", } list_columns = [ "id", @@ -305,6 +307,7 @@ class DatasetRestApi(BaseSupersetModelRestApi): DatasetRelatedObjectsResponse, DatasetDuplicateSchema, GetOrCreateDatasetSchema, + DatasetLineageResponseSchema, ) openapi_spec_methods = openapi_spec_methods_override @@ -850,6 +853,119 @@ def related_objects(self, id_or_uuid: str) -> Response: dashboards={"count": len(dashboards), "result": dashboards}, ) + @expose("//lineage", methods=("GET",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage", + log_to_statsd=False, + ) + def lineage(self, id_or_uuid: str) -> Response: + """Get lineage information for a dataset. + --- + get: + summary: Get lineage information for a dataset + description: >- + Returns upstream (database) and downstream (charts, dashboards) lineage + information for a dataset + parameters: + - in: path + name: id_or_uuid + schema: + type: string + description: Either the id of the dataset, or its uuid + responses: + 200: + description: Lineage information + content: + application/json: + schema: + $ref: "#/components/schemas/DatasetLineageResponseSchema" + 401: + $ref: '#/components/responses/401' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid) + if not dataset: + return self.response_404() + + dataset_info = { + "id": dataset.id, + "name": dataset.name, + "database_id": dataset.database_id, + "database_name": dataset.database.database_name + if dataset.database + else None, + "schema": dataset.schema, + "table_name": dataset.table_name, + } + + # Get upstream (database) information + upstream: dict[str, Any] = {} + if dataset.database: + upstream["database"] = { + "id": dataset.database.id, + "database_name": dataset.database.database_name, + "backend": dataset.database.backend, + } + else: + upstream["database"] = None + + # Get downstream (charts and dashboards) information + related_data = DatasetDAO.get_related_objects(dataset.id) + + # Build chart information with dashboard IDs + charts = [] + for chart in related_data["charts"]: + dashboard_ids = [d.id for d in chart.dashboards] + charts.append( + { + "id": chart.id, + "slice_name": chart.slice_name, + "viz_type": chart.viz_type, + "dashboard_ids": dashboard_ids, + } + ) + + # Build dashboard information with chart IDs + dashboards = [] + for dashboard in related_data["dashboards"]: + chart_ids = [ + chart.id + for chart in dashboard.slices + if chart.datasource_id == dataset.id + ] + dashboards.append( + { + "id": dashboard.id, + "title": dashboard.dashboard_title, + "slug": dashboard.slug, + "chart_ids": chart_ids, + } + ) + + downstream = { + "charts": { + "count": len(charts), + "result": charts, + }, + "dashboards": { + "count": len(dashboards), + "result": dashboards, + }, + } + + return self.response( + 200, + dataset=dataset_info, + upstream=upstream, + downstream=downstream, + ) + @expose("/", methods=("DELETE",)) @protect() @safe diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index 1506ef45d167..abe49af8fe93 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -234,6 +234,60 @@ class DatasetRelatedObjectsResponse(Schema): dashboards = fields.Nested(DatasetRelatedDashboards) +class DatasetLineageDatasetSchema(Schema): + id = fields.Integer() + name = fields.String() + database_id = fields.Integer() + database_name = fields.String() + schema = fields.String(allow_none=True) + table_name = fields.String() + + +class DatasetLineageDatabaseSchema(Schema): + id = fields.Integer() + database_name = fields.String() + backend = fields.String() + + +class DatasetLineageChartSchema(Schema): + id = fields.Integer() + slice_name = fields.String() + viz_type = fields.String() + dashboard_ids = fields.List(fields.Integer()) + + +class DatasetLineageDashboardSchema(Schema): + id = fields.Integer() + title = fields.String() + slug = fields.String() + chart_ids = fields.List(fields.Integer()) + + +class DatasetLineageUpstreamSchema(Schema): + database = fields.Nested(DatasetLineageDatabaseSchema, allow_none=True) + + +class DatasetLineageDownstreamChartsSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(DatasetLineageChartSchema)) + + +class DatasetLineageDownstreamDashboardsSchema(Schema): + count = fields.Integer() + result = fields.List(fields.Nested(DatasetLineageDashboardSchema)) + + +class DatasetLineageDownstreamSchema(Schema): + charts = fields.Nested(DatasetLineageDownstreamChartsSchema) + dashboards = fields.Nested(DatasetLineageDownstreamDashboardsSchema) + + +class DatasetLineageResponseSchema(Schema): + dataset = fields.Nested(DatasetLineageDatasetSchema) + upstream = fields.Nested(DatasetLineageUpstreamSchema) + downstream = fields.Nested(DatasetLineageDownstreamSchema) + + class ImportV1ColumnSchema(Schema): # pylint: disable=unused-argument @pre_load From 4900cecf685e0184e2a7eea1bbde1ec8fb39ae69 Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Thu, 5 Feb 2026 00:19:04 -0500 Subject: [PATCH 2/6] feat: small improve --- superset/datasets/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/superset/datasets/api.py b/superset/datasets/api.py index efd7e0f2466b..98018d9e9073 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -897,9 +897,9 @@ def lineage(self, id_or_uuid: str) -> Response: "id": dataset.id, "name": dataset.name, "database_id": dataset.database_id, - "database_name": dataset.database.database_name - if dataset.database - else None, + "database_name": ( + dataset.database.database_name if dataset.database else None + ), "schema": dataset.schema, "table_name": dataset.table_name, } From c2c78b86fe04887d0acf327c1536fcc603b48a55 Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Thu, 5 Feb 2026 08:38:41 -0500 Subject: [PATCH 3/6] feat: include lineage endpoint tests --- tests/integration_tests/charts/api_tests.py | 32 +++ .../integration_tests/dashboards/api_tests.py | 32 +++ tests/integration_tests/datasets/api_tests.py | 32 +++ tests/integration_tests/fixtures/lineage.py | 266 ++++++++++++++++++ 4 files changed, 362 insertions(+) create mode 100644 tests/integration_tests/fixtures/lineage.py diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py index b8b60355419a..351d7c0a16c8 100644 --- a/tests/integration_tests/charts/api_tests.py +++ b/tests/integration_tests/charts/api_tests.py @@ -50,6 +50,7 @@ load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_data, # noqa: F401 ) +from tests.integration_tests.fixtures.client import client # noqa: F401 from tests.integration_tests.fixtures.energy_dashboard import ( load_energy_table_data, # noqa: F401 load_energy_table_with_slice, # noqa: F401 @@ -59,6 +60,10 @@ 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, @@ -2317,3 +2322,30 @@ def test_update_chart_no_tag_changes(self): security_manager.add_permission_role(alpha_role, write_tags_perm) security_manager.add_permission_role(alpha_role, tag_charts_perm) + + @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")) + + # Assert the entire response matches expected structure + assert data == 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 ca0e8fa8968e..2c6f823ab514 100644 --- a/tests/integration_tests/dashboards/api_tests.py +++ b/tests/integration_tests/dashboards/api_tests.py @@ -66,6 +66,11 @@ 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 @@ -3818,6 +3823,33 @@ def test_put_dashboard_colors_not_authorized(self): 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")) + + # Assert the entire response matches expected structure + assert data == 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 6677f19f8ad1..a9f1760775c0 100644 --- a/tests/integration_tests/datasets/api_tests.py +++ b/tests/integration_tests/datasets/api_tests.py @@ -55,6 +55,7 @@ load_birth_names_dashboard_with_slices, # noqa: F401 load_birth_names_data, # noqa: F401 ) +from tests.integration_tests.fixtures.client import client # noqa: F401 from tests.integration_tests.fixtures.energy_dashboard import ( load_energy_table_data, # noqa: F401 load_energy_table_with_slice, # noqa: F401 @@ -64,6 +65,10 @@ dataset_config, dataset_ui_export, ) +from tests.integration_tests.fixtures.lineage import ( + inject_expected_dataset_lineage, # noqa: F401 + lineage_test_data, # noqa: F401 +) class TestDatasetApi(SupersetTestCase): @@ -3532,3 +3537,30 @@ def test_get_drill_info_dashboard_rbac_no_dashboard_id(self): assert rv.status_code == 404 self.items_to_delete = [dashboard, chart, dataset] + + @pytest.mark.usefixtures("inject_expected_dataset_lineage") + def test_get_dataset_lineage(self): + """ + Dataset API: Test get dataset lineage + """ + self.login(ADMIN_USERNAME) + dataset_id = self.dataset_lineage["dataset_id"] + expected = self.dataset_lineage["expected"] + + uri = f"api/v1/dataset/{dataset_id}/lineage" + rv = self.get_assert_metric(uri, "lineage") + assert rv.status_code == 200 + + data = json.loads(rv.data.decode("utf-8")) + + # Assert the entire response matches expected structure + assert data == 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 diff --git a/tests/integration_tests/fixtures/lineage.py b/tests/integration_tests/fixtures/lineage.py new file mode 100644 index 000000000000..e4d606b3cb27 --- /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, + }, + } From 375cd7f21add4218b61ccb0b2b25245de926ee6b Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Mon, 9 Feb 2026 05:26:58 -0500 Subject: [PATCH 4/6] feat: lineage ui implementation --- .../DatasourceEditor/DatasourceEditor.jsx | 28 +++ .../Header/useHeaderActionsDropdownMenu.tsx | 19 ++ superset-frontend/src/dashboard/types.ts | 1 + .../useExploreAdditionalActionsMenu/index.jsx | 19 ++ .../src/features/charts/ChartCard.tsx | 128 ++++++---- .../src/features/dashboards/DashboardCard.tsx | 122 +++++---- .../datasets/AddDataset/EditDataset/index.tsx | 12 + .../src/features/lineage/LineageModal.tsx | 79 ++++++ .../src/features/lineage/LineageView.tsx | 233 ++++++++++++++++++ .../src/features/lineage/index.ts | 21 ++ .../src/hooks/apiResources/index.ts | 1 + .../src/hooks/apiResources/lineage.ts | 134 ++++++++++ superset/charts/api.py | 12 +- superset/dashboards/api.py | 12 +- superset/datasets/api.py | 12 +- 15 files changed, 717 insertions(+), 116 deletions(-) create mode 100644 superset-frontend/src/features/lineage/LineageModal.tsx create mode 100644 superset-frontend/src/features/lineage/LineageView.tsx create mode 100644 superset-frontend/src/features/lineage/index.ts create mode 100644 superset-frontend/src/hooks/apiResources/lineage.ts diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx index 4895f632f0ee..ea637db6cd9a 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx @@ -88,9 +88,27 @@ import { } from '../../FoldersEditor/constants'; import { validateFolders } from '../../FoldersEditor/folderValidation'; import FoldersEditor from '../../FoldersEditor'; +import { useDatasetLineage } from 'src/hooks/apiResources'; +import { LineageView } from 'src/features/lineage'; const extensionsRegistry = getExtensionsRegistry(); +// Functional wrapper component for lineage tab (needed because hooks can't be used in class components) +function DatasetLineageTab({ datasourceId }) { + const lineageResource = useDatasetLineage(datasourceId); + + // Show loading state if datasourceId is not available + if (!datasourceId) { + return ; + } + + return ; +} + +DatasetLineageTab.propTypes = { + datasourceId: PropTypes.number, +}; + const DatasourceContainer = styled.div` .change-warning { margin: 16px 10px 0; @@ -217,6 +235,7 @@ const TABS_KEYS = { COLUMNS: 'COLUMNS', CALCULATED_COLUMNS: 'CALCULATED_COLUMNS', USAGE: 'USAGE', + LINEAGE: 'LINEAGE', FOLDERS: 'FOLDERS', SETTINGS: 'SETTINGS', SPATIAL: 'SPATIAL', @@ -2068,6 +2087,15 @@ class DatasourceEditor extends PureComponent { ), }, + { + key: TABS_KEYS.LINEAGE, + label: t('Lineage'), + children: ( + + + + ), + }, ...(isFeatureEnabled(FeatureFlag.DatasetFolders) ? [ { diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index ef6be029e2b1..ab974176f1bf 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -35,6 +35,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { getUrlParam } from 'src/utils/urlUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import { HeaderDropdownProps } from 'src/dashboard/components/Header/types'; +import { LineageModal } from 'src/features/lineage'; export const useHeaderActionsMenu = ({ customCss, @@ -217,6 +218,24 @@ export const useHeaderActionsMenu = ({ key: MenuKeys.EditProperties, label: t('Edit properties'), }); + + // View lineage + if (dashboardId) { + menuItems.push( + createModalMenuItem( + MenuKeys.ViewLineage, + + {t('View lineage')} + + } + />, + ), + ); + } } // Divider diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index b2bde9683681..4c57f3b065f6 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -310,4 +310,5 @@ export enum MenuKeys { ManageEmailReports = 'manage_email_reports', ExportPivotXlsx = 'export_pivot_xlsx', EmbedCode = 'embed_code', + ViewLineage = 'view_lineage', } diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 05c9e1997295..489c4004a420 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -49,6 +49,7 @@ import { useStreamingExport } from 'src/components/StreamingExportModal'; import ViewQueryModal from '../controls/ViewQueryModal'; import EmbedCodeContent from '../EmbedCodeContent'; import { useDashboardsMenuItems } from './DashboardsSubMenu'; +import { LineageModal } from 'src/features/lineage'; export const SEARCH_THRESHOLD = 10; @@ -79,6 +80,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', }; @@ -859,6 +861,23 @@ export const useExploreAdditionalActionsMenu = ( onClick: () => setIsDropdownVisible(false), }); + // View lineage + if (slice?.slice_id) { + menuItems.push({ + key: MENU_KEYS.VIEW_LINEAGE, + label: ( + {t('View lineage')} + } + /> + ), + 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 a6c1b9d7f1d3..23ebc3fb8ac8 100644 --- a/superset-frontend/src/features/charts/ChartCard.tsx +++ b/superset-frontend/src/features/charts/ChartCard.tsx @@ -34,6 +34,7 @@ import Chart from 'src/types/Chart'; import { FacePile } from 'src/components'; import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils'; import { assetUrl } from 'src/utils/assetUrl'; +import { LineageModal } from 'src/features/lineage'; interface ChartCardProps { chart: Chart; @@ -72,6 +73,7 @@ export default function ChartCard({ const canEdit = hasPerm('can_write'); const canDelete = hasPerm('can_write'); const canExport = hasPerm('can_export'); + const canRead = hasPerm('can_read'); const menuItems: MenuItem[] = []; if (canEdit) { @@ -96,6 +98,29 @@ export default function ChartCard({ }); } + if (canRead) { + menuItems.push({ + key: 'lineage', + label: ( + + {' '} + {t('View Lineage')} + + } + /> + ), + }); + } + if (canExport) { menuItems.push({ key: 'export', @@ -162,54 +187,59 @@ export default function ChartCard({ } return ( - { - if (!bulkSelectEnabled && chart.url) { - history.push(chart.url); - } - }} - > - - ) : null - } - url={bulkSelectEnabled ? undefined : chart.url} - imgURL={chart.thumbnail_url || ''} - imgFallbackURL={assetUrl( - '/static/assets/images/chart-card-fallback.svg', - )} - description={t('Modified %s', chart.changed_on_delta_humanized)} - coverLeft={} - coverRight={} - linkComponent={Link} - actions={ - { - e.stopPropagation(); - e.preventDefault(); - }} - > - {userId && ( - - )} - - - - - } - /> - + <> + { + if (!bulkSelectEnabled && chart.url) { + history.push(chart.url); + } + }} + > + + ) : null + } + url={bulkSelectEnabled ? undefined : chart.url} + imgURL={chart.thumbnail_url || ''} + imgFallbackURL={assetUrl( + '/static/assets/images/chart-card-fallback.svg', + )} + description={t('Modified %s', chart.changed_on_delta_humanized)} + coverLeft={} + coverRight={} + linkComponent={Link} + actions={ + { + e.stopPropagation(); + e.preventDefault(); + }} + > + {userId && ( + + )} + + + + + } + /> + + ); } diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx index a80965787ab8..941edc704d37 100644 --- a/superset-frontend/src/features/dashboards/DashboardCard.tsx +++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx @@ -37,6 +37,7 @@ import { Icons } from '@superset-ui/core/components/Icons'; import { Dashboard } from 'src/views/CRUD/types'; import { assetUrl } from 'src/utils/assetUrl'; import { FacePile } from 'src/components'; +import { LineageModal } from 'src/features/lineage'; interface DashboardCardProps { isChart?: boolean; @@ -69,6 +70,7 @@ function DashboardCard({ const canEdit = hasPerm('can_write'); const canDelete = hasPerm('can_write'); const canExport = hasPerm('can_export'); + const canRead = hasPerm('can_read'); const [thumbnailUrl, setThumbnailUrl] = useState(null); const [fetchingThumbnail, setFetchingThumbnail] = useState(false); @@ -99,6 +101,23 @@ function DashboardCard({ const menuItems: MenuItem[] = []; + if (canRead) { + menuItems.push({ + key: 'lineage', + label: ( + + {t('View Lineage')} + + } + /> + ), + }); + } + if (canEdit && openDashboardEditModal) { menuItems.push({ key: 'edit', @@ -151,55 +170,60 @@ function DashboardCard({ } return ( - { - if (!bulkSelectEnabled) { - history.push(dashboard.url); - } - }} - > - } - cover={ - !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? ( - <> - ) : null - } - url={bulkSelectEnabled ? undefined : dashboard.url} - linkComponent={Link} - imgURL={thumbnailUrl} - imgFallbackURL={assetUrl( - '/static/assets/images/dashboard-card-fallback.svg', - )} - description={t('Modified %s', dashboard.changed_on_delta_humanized)} - coverLeft={} - actions={ - { - e.stopPropagation(); - e.preventDefault(); - }} - > - {userId && ( - - )} - - - - - } - /> - + <> + { + if (!bulkSelectEnabled) { + history.push(dashboard.url); + } + }} + > + } + cover={ + !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? ( + <> + ) : null + } + url={bulkSelectEnabled ? undefined : dashboard.url} + linkComponent={Link} + imgURL={thumbnailUrl} + imgFallbackURL={assetUrl( + '/static/assets/images/dashboard-card-fallback.svg', + )} + description={t('Modified %s', dashboard.changed_on_delta_humanized)} + coverLeft={} + actions={ + { + e.stopPropagation(); + e.preventDefault(); + }} + > + {userId && ( + + )} + + + + + } + /> + + ); } diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx index 833e1e07fb38..1ff5d16054a6 100644 --- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx +++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx @@ -21,6 +21,8 @@ import { styled } from '@apache-superset/core/ui'; 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 +53,19 @@ 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 lineageResource = useDatasetLineage(id); const usageTab = ( @@ -85,6 +90,13 @@ const EditPage = ({ id }: EditPageProps) => { label: usageTab, children: null, }, + { + key: TABS_KEYS.LINEAGE, + label: TRANSLATIONS.LINEAGE_TEXT, + children: ( + + ), + }, ]; return ; diff --git a/superset-frontend/src/features/lineage/LineageModal.tsx b/superset-frontend/src/features/lineage/LineageModal.tsx new file mode 100644 index 000000000000..e3d67b8ce8e6 --- /dev/null +++ b/superset-frontend/src/features/lineage/LineageModal.tsx @@ -0,0 +1,79 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { FC, ReactNode } from 'react'; +import { t } from '@apache-superset/core'; +import { ModalTrigger } from '@superset-ui/core/components'; +import { + useChartLineage, + useDashboardLineage, + useDatasetLineage, +} from 'src/hooks/apiResources'; +import LineageView from './LineageView'; + +export interface LineageModalProps { + entityType: 'dataset' | 'chart' | 'dashboard'; + entityId: string | number; + triggerNode: ReactNode; +} + +const LineageModal: FC = ({ + entityType, + entityId, + triggerNode, +}) => { + const datasetLineage = useDatasetLineage( + entityType === 'dataset' ? entityId : '', + ); + const chartLineage = useChartLineage(entityType === 'chart' ? entityId : ''); + const dashboardLineage = useDashboardLineage( + entityType === 'dashboard' ? entityId : '', + ); + + const lineageResource = + entityType === 'dataset' + ? datasetLineage + : entityType === 'chart' + ? chartLineage + : dashboardLineage; + + const title = + entityType === 'dataset' + ? t('Dataset Lineage') + : entityType === 'chart' + ? t('Chart Lineage') + : t('Dashboard Lineage'); + + return ( + + } + width="800px" + 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 000000000000..5c836169c2be --- /dev/null +++ b/superset-frontend/src/features/lineage/LineageView.tsx @@ -0,0 +1,233 @@ +/** + * 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 } from 'react'; +import { t, useTheme } from '@apache-superset/core/ui'; +import { Empty, Loading } from '@superset-ui/core/components'; +import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; +import type { Resource } from 'src/hooks/apiResources/apiResources'; +import type { + DatasetLineage, + ChartLineage, + DashboardLineage, + ChartEntity, + DashboardEntity, +} from 'src/hooks/apiResources/lineage'; +import Echart from '../../../plugins/plugin-chart-echarts/src/components/Echart'; +import type { EChartsCoreOption } from 'echarts/core'; + +type LineageViewProps = { + lineageResource: + | Resource + | Resource + | Resource; + entityType: 'dataset' | 'chart' | 'dashboard'; +}; + +const LineageView: FC = ({ lineageResource, entityType }) => { + const theme = useTheme(); + + const echartOptions: EChartsCoreOption | null = useMemo(() => { + if ( + lineageResource.status !== ResourceStatus.Complete || + !lineageResource.result + ) { + return null; + } + + const data = lineageResource.result; + const nodes: { name: string; itemStyle?: { color: string } }[] = []; + const links: { source: string; target: string; value: number }[] = []; + const nodeSet = new Set(); + + // Helper to add a node + const addNode = (name: string, color: string) => { + if (!nodeSet.has(name)) { + nodeSet.add(name); + nodes.push({ + name, + itemStyle: { color }, + }); + } + }; + + // Helper to add a link + 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; + + // Add current dataset node (center) + addNode(dataset.name, theme.colorPrimary); + + // Add upstream database + if (upstream?.database) { + addNode(upstream.database.database_name, theme.colorInfo); + addLink(upstream.database.database_name, dataset.name); + } + + // Add downstream charts + const chartMap = new Map(); + if (downstream?.charts?.result) { + downstream.charts.result.forEach((chart: ChartEntity) => { + chartMap.set(chart.id, chart); + addNode(chart.slice_name, theme.colorSuccess); + addLink(dataset.name, chart.slice_name); + }); + } + + // Add downstream dashboards and link to their specific charts + if (downstream?.dashboards?.result) { + downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { + addNode(dashboard.title, theme.colorWarning); + + // Link from charts to dashboards using chart_ids + if (dashboard.chart_ids && dashboard.chart_ids.length > 0) { + dashboard.chart_ids.forEach(chartId => { + const chart = chartMap.get(chartId); + if (chart) { + addLink(chart.slice_name, dashboard.title); + } + }); + } + }); + } + } else if (entityType === 'chart' && 'chart' in data) { + const { chart, upstream, downstream } = data as ChartLineage; + + // Add current chart node (center) + addNode(chart.slice_name, theme.colorPrimary); + + // Add upstream dataset + if (upstream?.dataset) { + addNode(upstream.dataset.name, theme.colorInfo); + addLink(upstream.dataset.name, chart.slice_name); + + // Add upstream database + if (upstream.database) { + addNode(upstream.database.database_name, theme.colorWarning); + addLink(upstream.database.database_name, upstream.dataset.name); + } + } + + // Add downstream dashboards + if (downstream?.dashboards?.result) { + downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { + addNode(dashboard.title, theme.colorSuccess); + addLink(chart.slice_name, dashboard.title); + }); + } + } else if (entityType === 'dashboard' && 'dashboard' in data) { + const { dashboard, upstream } = data as DashboardLineage; + + // Add current dashboard node (right) + addNode(dashboard.title, theme.colorPrimary); + + // Create a map of chart id to chart for easy lookup + const chartMap = new Map(); + if (upstream?.charts?.result) { + upstream.charts.result.forEach((chart: ChartEntity) => { + chartMap.set(chart.id, chart); + addNode(chart.slice_name, theme.colorInfo); + addLink(chart.slice_name, dashboard.title); + }); + } + + // Create a map of dataset id to dataset for easy lookup + const datasetMap = new Map(); + if (upstream?.datasets?.result) { + upstream.datasets.result.forEach(dataset => { + datasetMap.set(dataset.id, dataset); + addNode(dataset.name, theme.colorSuccess); + + // Link datasets to their specific charts using chart_ids + if (dataset.chart_ids && dataset.chart_ids.length > 0) { + dataset.chart_ids.forEach(chartId => { + const chart = chartMap.get(chartId); + if (chart) { + addLink(dataset.name, chart.slice_name); + } + }); + } + }); + } + + // Add upstream databases and link to their specific datasets + if (upstream?.databases?.result) { + upstream.databases.result.forEach(database => { + addNode(database.database_name, theme.colorWarning); + + // 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) { + addLink(database.database_name, dataset.name); + } + }); + } + }); + } + } + + return { + series: { + animation: false, + data: nodes, + lineStyle: { + color: 'source', + }, + links, + type: 'sankey', + }, + tooltip: { + trigger: 'item', + triggerOn: 'mousemove', + }, + }; + }, [lineageResource, entityType, theme]); + + if (lineageResource.status === ResourceStatus.Loading) { + return ; + } + + if ( + lineageResource.status === ResourceStatus.Error || + !lineageResource.result + ) { + return ; + } + + if (!echartOptions) { + return ; + } + + return ( + + ); +}; + +export default LineageView; diff --git a/superset-frontend/src/features/lineage/index.ts b/superset-frontend/src/features/lineage/index.ts new file mode 100644 index 000000000000..b197eca9258e --- /dev/null +++ b/superset-frontend/src/features/lineage/index.ts @@ -0,0 +1,21 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { default as LineageView } from './LineageView'; +export { default as LineageModal } from './LineageModal'; diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts index 53aa7aa113cb..61d3628a61b8 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.ts b/superset-frontend/src/hooks/apiResources/lineage.ts new file mode 100644 index 000000000000..87011a2f7f6c --- /dev/null +++ b/superset-frontend/src/hooks/apiResources/lineage.ts @@ -0,0 +1,134 @@ +/** + * 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; +}; + +/** + * Hook to fetch lineage data for a dataset + * @param idOrUuid Dataset ID or UUID + */ +export const useDatasetLineage = (idOrUuid: string | number) => + useApiV1Resource(`/api/v1/dataset/${idOrUuid}/lineage`); + +/** + * Hook to fetch lineage data for a chart + * @param idOrUuid Chart ID or UUID + */ +export const useChartLineage = (idOrUuid: string | number) => + useApiV1Resource(`/api/v1/chart/${idOrUuid}/lineage`); + +/** + * Hook to fetch lineage data for a dashboard + * @param idOrSlug Dashboard ID or slug + */ +export const useDashboardLineage = (idOrSlug: string | number) => + useApiV1Resource(`/api/v1/dashboard/${idOrSlug}/lineage`); diff --git a/superset/charts/api.py b/superset/charts/api.py index 8a1c7250ab4b..66ce4acbc91b 100644 --- a/superset/charts/api.py +++ b/superset/charts/api.py @@ -402,12 +402,12 @@ def lineage(self, id_or_uuid: str) -> Response: }, } - return self.response( - 200, - chart=chart_info, - upstream=upstream, - downstream=downstream, - ) + result = { + "chart": chart_info, + "upstream": upstream, + "downstream": downstream, + } + return self.response(200, result=result) @expose("/", methods=("POST",)) @protect() diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py index b0ac3724b0f2..e1643709b3c2 100644 --- a/superset/dashboards/api.py +++ b/superset/dashboards/api.py @@ -593,12 +593,12 @@ def lineage(self, dash: Dashboard) -> Response: }, } - return self.response( - 200, - dashboard=dashboard_info, - upstream=upstream, - downstream=None, - ) + result = { + "dashboard": dashboard_info, + "upstream": upstream, + "downstream": None, + } + return self.response(200, result=result) @expose("//datasets", methods=("GET",)) @protect() diff --git a/superset/datasets/api.py b/superset/datasets/api.py index 98018d9e9073..2cc690a6b801 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -959,12 +959,12 @@ def lineage(self, id_or_uuid: str) -> Response: }, } - return self.response( - 200, - dataset=dataset_info, - upstream=upstream, - downstream=downstream, - ) + result = { + "dataset": dataset_info, + "upstream": upstream, + "downstream": downstream, + } + return self.response(200, result=result) @expose("/", methods=("DELETE",)) @protect() From 508f7fa9654310d442d64c5d24ccb53df2212acd Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Mon, 9 Feb 2026 05:56:09 -0500 Subject: [PATCH 5/6] feat: include legend --- .../src/features/lineage/LineageModal.tsx | 2 +- .../src/features/lineage/LineageView.tsx | 151 ++++++++++++++---- 2 files changed, 120 insertions(+), 33 deletions(-) diff --git a/superset-frontend/src/features/lineage/LineageModal.tsx b/superset-frontend/src/features/lineage/LineageModal.tsx index e3d67b8ce8e6..2d75cab4afc7 100644 --- a/superset-frontend/src/features/lineage/LineageModal.tsx +++ b/superset-frontend/src/features/lineage/LineageModal.tsx @@ -69,7 +69,7 @@ const LineageModal: FC = ({ entityType={entityType} /> } - width="800px" + width="850px" responsive destroyOnHidden /> diff --git a/superset-frontend/src/features/lineage/LineageView.tsx b/superset-frontend/src/features/lineage/LineageView.tsx index 5c836169c2be..df6852ce12c5 100644 --- a/superset-frontend/src/features/lineage/LineageView.tsx +++ b/superset-frontend/src/features/lineage/LineageView.tsx @@ -17,7 +17,7 @@ * under the License. */ import { FC, useMemo } from 'react'; -import { t, useTheme } from '@apache-superset/core/ui'; +import { t, useTheme, styled } from '@apache-superset/core/ui'; import { Empty, Loading } from '@superset-ui/core/components'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import type { Resource } from 'src/hooks/apiResources/apiResources'; @@ -31,6 +31,43 @@ import type { 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}; + } + `} +`; + type LineageViewProps = { lineageResource: | Resource @@ -51,17 +88,28 @@ const LineageView: FC = ({ lineageResource, entityType }) => { } const data = lineageResource.result; - const nodes: { name: string; itemStyle?: { color: string } }[] = []; + const nodes: { + name: string; + itemStyle?: { color: string }; + label?: { position?: string }; + }[] = []; const links: { source: string; target: string; value: number }[] = []; const nodeSet = new Set(); - // Helper to add a node - const addNode = (name: string, color: string) => { + // Helper to add a node with label position + const addNode = ( + name: string, + color: string, + labelPosition: 'left' | 'right' | 'inside', + ) => { if (!nodeSet.has(name)) { nodeSet.add(name); nodes.push({ name, itemStyle: { color }, + label: { + position: labelPosition, + }, }); } }; @@ -75,29 +123,29 @@ const LineageView: FC = ({ lineageResource, entityType }) => { if (entityType === 'dataset' && 'dataset' in data) { const { dataset, upstream, downstream } = data as DatasetLineage; - // Add current dataset node (center) - addNode(dataset.name, theme.colorPrimary); + // Add current dataset node (center) - label inside + addNode(dataset.name, theme.colorPrimary, 'inside'); - // Add upstream database + // Add upstream database - label on left if (upstream?.database) { - addNode(upstream.database.database_name, theme.colorInfo); + addNode(upstream.database.database_name, theme.colorInfo, 'left'); addLink(upstream.database.database_name, dataset.name); } - // Add downstream charts + // Add downstream charts - label on right const chartMap = new Map(); if (downstream?.charts?.result) { downstream.charts.result.forEach((chart: ChartEntity) => { chartMap.set(chart.id, chart); - addNode(chart.slice_name, theme.colorSuccess); + addNode(chart.slice_name, theme.colorSuccess, 'right'); addLink(dataset.name, chart.slice_name); }); } - // Add downstream dashboards and link to their specific charts + // Add downstream dashboards - label on right if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - addNode(dashboard.title, theme.colorWarning); + addNode(dashboard.title, theme.colorWarning, 'right'); // Link from charts to dashboards using chart_ids if (dashboard.chart_ids && dashboard.chart_ids.length > 0) { @@ -113,40 +161,41 @@ const LineageView: FC = ({ lineageResource, entityType }) => { } else if (entityType === 'chart' && 'chart' in data) { const { chart, upstream, downstream } = data as ChartLineage; - // Add current chart node (center) - addNode(chart.slice_name, theme.colorPrimary); + // Add current chart node (center) - label inside + addNode(chart.slice_name, theme.colorPrimary, 'inside'); - // Add upstream dataset + // Add upstream dataset - label on left if (upstream?.dataset) { - addNode(upstream.dataset.name, theme.colorInfo); + addNode(upstream.dataset.name, theme.colorInfo, 'left'); addLink(upstream.dataset.name, chart.slice_name); - // Add upstream database + // Add upstream database - label on left if (upstream.database) { - addNode(upstream.database.database_name, theme.colorWarning); + addNode(upstream.database.database_name, theme.colorWarning, 'left'); addLink(upstream.database.database_name, upstream.dataset.name); } } - // Add downstream dashboards + // Add downstream dashboards - label on right if (downstream?.dashboards?.result) { downstream.dashboards.result.forEach((dashboard: DashboardEntity) => { - addNode(dashboard.title, theme.colorSuccess); + addNode(dashboard.title, theme.colorSuccess, 'right'); addLink(chart.slice_name, dashboard.title); }); } } else if (entityType === 'dashboard' && 'dashboard' in data) { const { dashboard, upstream } = data as DashboardLineage; - // Add current dashboard node (right) - addNode(dashboard.title, theme.colorPrimary); + // Add current dashboard node (right) - label inside + addNode(dashboard.title, theme.colorPrimary, 'inside'); // Create a map of chart id to chart for easy lookup const chartMap = new Map(); if (upstream?.charts?.result) { upstream.charts.result.forEach((chart: ChartEntity) => { chartMap.set(chart.id, chart); - addNode(chart.slice_name, theme.colorInfo); + // Charts are upstream - label on left + addNode(chart.slice_name, theme.colorInfo, 'left'); addLink(chart.slice_name, dashboard.title); }); } @@ -156,7 +205,8 @@ const LineageView: FC = ({ lineageResource, entityType }) => { if (upstream?.datasets?.result) { upstream.datasets.result.forEach(dataset => { datasetMap.set(dataset.id, dataset); - addNode(dataset.name, theme.colorSuccess); + // Datasets are upstream - label on left + addNode(dataset.name, theme.colorSuccess, 'left'); // Link datasets to their specific charts using chart_ids if (dataset.chart_ids && dataset.chart_ids.length > 0) { @@ -173,7 +223,8 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add upstream databases and link to their specific datasets if (upstream?.databases?.result) { upstream.databases.result.forEach(database => { - addNode(database.database_name, theme.colorWarning); + // Databases are upstream - label on left + addNode(database.database_name, theme.colorWarning, 'left'); // Link databases to datasets that belong to them using database_id if (upstream.datasets?.result) { @@ -204,6 +255,33 @@ const LineageView: FC = ({ lineageResource, entityType }) => { }; }, [lineageResource, entityType, theme]); + // Build legend data based on entity type + const legendItems: { label: string; color: string }[] = useMemo(() => { + if (entityType === 'dataset') { + return [ + { label: 'Database (Upstream)', color: theme.colorInfo }, + { label: 'Dataset (Current)', color: theme.colorPrimary }, + { label: 'Chart (Downstream)', color: theme.colorSuccess }, + { label: 'Dashboard (Downstream)', color: theme.colorWarning }, + ]; + } else if (entityType === 'chart') { + return [ + { label: 'Database (Upstream)', color: theme.colorWarning }, + { label: 'Dataset (Upstream)', color: theme.colorInfo }, + { label: 'Chart (Current)', color: theme.colorPrimary }, + { label: 'Dashboard (Downstream)', color: theme.colorSuccess }, + ]; + } else if (entityType === 'dashboard') { + return [ + { label: 'Database (Upstream)', color: theme.colorWarning }, + { label: 'Dataset (Upstream)', color: theme.colorSuccess }, + { label: 'Chart (Upstream)', color: theme.colorInfo }, + { label: 'Dashboard (Current)', color: theme.colorPrimary }, + ]; + } + return []; + }, [entityType, theme]); + if (lineageResource.status === ResourceStatus.Loading) { return ; } @@ -220,13 +298,22 @@ const LineageView: FC = ({ lineageResource, entityType }) => { } return ( - + + + {legendItems.map(item => ( + + {item.label} + + ))} + + + ); }; From 92b9ad42d6f0a2ebe986fbf2c3d08caf3bd2770a Mon Sep 17 00:00:00 2001 From: Jonathan Alberth Quispe Fuentes Date: Mon, 9 Feb 2026 07:27:44 -0500 Subject: [PATCH 6/6] feat: details panerl and minor fixes --- .../src/features/lineage/LineageView.tsx | 385 +++++++++++++++++- 1 file changed, 371 insertions(+), 14 deletions(-) diff --git a/superset-frontend/src/features/lineage/LineageView.tsx b/superset-frontend/src/features/lineage/LineageView.tsx index df6852ce12c5..16c45741ac02 100644 --- a/superset-frontend/src/features/lineage/LineageView.tsx +++ b/superset-frontend/src/features/lineage/LineageView.tsx @@ -16,9 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { FC, useMemo } from 'react'; +import { FC, useMemo, useState, useCallback } from 'react'; import { t, useTheme, styled } from '@apache-superset/core/ui'; import { Empty, Loading } from '@superset-ui/core/components'; +import { Button } from '@superset-ui/core/components'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import type { Resource } from 'src/hooks/apiResources/apiResources'; import type { @@ -27,6 +28,8 @@ import type { 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'; @@ -68,6 +71,77 @@ const LegendItem = styled.div<{ color: string }>` `} `; +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 NodeDetails = { + name: string; + type: 'database' | 'dataset' | 'chart' | 'dashboard'; + id?: number; + additionalInfo?: Record; +}; + type LineageViewProps = { lineageResource: | Resource @@ -78,6 +152,204 @@ type LineageViewProps = { const LineageView: FC = ({ lineageResource, entityType }) => { const theme = useTheme(); + const [selectedNode, setSelectedNode] = useState(null); + + // Create a mapping of node names to their details + const nodeDetailsMap = useMemo(() => { + if ( + lineageResource.status !== ResourceStatus.Complete || + !lineageResource.result + ) { + return new Map(); + } + + const data = lineageResource.result; + const map = new Map(); + + if (entityType === 'dataset' && 'dataset' in data) { + const { dataset, upstream, downstream } = data as DatasetLineage; + + // Add current dataset + map.set(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(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(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(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(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(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(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(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(dashboard.title, { + name: dashboard.title, + type: 'dashboard', + id: dashboard.id, + additionalInfo: { + slug: dashboard.slug, + }, + }); + + // First pass: detect duplicate chart names + const chartNameCounts = new Map(); + if (upstream?.charts?.result) { + upstream.charts.result.forEach((chart: ChartEntity) => { + const count = chartNameCounts.get(chart.slice_name) || 0; + chartNameCounts.set(chart.slice_name, count + 1); + }); + } + + // Add upstream charts + if (upstream?.charts?.result) { + upstream.charts.result.forEach((chart: ChartEntity) => { + // Only append ID if there are duplicate names + const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1; + const chartNodeName = hasDuplicate + ? `${chart.slice_name} (#${chart.id})` + : chart.slice_name; + map.set(chartNodeName, { + 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(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(database.database_name, { + name: database.database_name, + type: 'database', + id: database.id, + }); + }); + } + } + + return map; + }, [lineageResource, entityType]); + + // Handle node click + const handleNodeClick = useCallback((params: any) => { + if (params.dataType === 'node') { + const nodeName = params.name; + const nodeDetails = nodeDetailsMap.get(nodeName); + if (nodeDetails) { + setSelectedNode(nodeDetails); + } + } + // Always stop event propagation to prevent tooltip issues + if (params.event) { + params.event.stop(); + } + }, [nodeDetailsMap]); const echartOptions: EChartsCoreOption | null = useMemo(() => { if ( @@ -189,14 +461,30 @@ const LineageView: FC = ({ lineageResource, entityType }) => { // Add current dashboard node (right) - label inside addNode(dashboard.title, theme.colorPrimary, 'inside'); + // First pass: detect duplicate chart names + const chartNameCounts = new Map(); + if (upstream?.charts?.result) { + upstream.charts.result.forEach((chart: ChartEntity) => { + const count = chartNameCounts.get(chart.slice_name) || 0; + chartNameCounts.set(chart.slice_name, count + 1); + }); + } + // Create a map of chart id to chart for easy lookup const chartMap = new Map(); + const chartNodeNames = new Map(); // Map chart ID to its node name if (upstream?.charts?.result) { upstream.charts.result.forEach((chart: ChartEntity) => { chartMap.set(chart.id, chart); + // Only append ID if there are duplicate names + const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1; + const chartNodeName = hasDuplicate + ? `${chart.slice_name} (#${chart.id})` + : chart.slice_name; + chartNodeNames.set(chart.id, chartNodeName); // Charts are upstream - label on left - addNode(chart.slice_name, theme.colorInfo, 'left'); - addLink(chart.slice_name, dashboard.title); + addNode(chartNodeName, theme.colorInfo, 'left'); + addLink(chartNodeName, dashboard.title); }); } @@ -207,15 +495,18 @@ const LineageView: FC = ({ lineageResource, entityType }) => { datasetMap.set(dataset.id, dataset); // Datasets are upstream - label on left addNode(dataset.name, theme.colorSuccess, 'left'); + }); + } - // Link datasets to their specific charts using chart_ids - if (dataset.chart_ids && dataset.chart_ids.length > 0) { - dataset.chart_ids.forEach(chartId => { - const chart = chartMap.get(chartId); - if (chart) { - addLink(dataset.name, chart.slice_name); - } - }); + // 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 dataset = datasetMap.get(chart.dataset_id); + const chartNodeName = chartNodeNames.get(chart.id); + if (dataset && chartNodeName) { + addLink(dataset.name, chartNodeName); + } } }); } @@ -249,8 +540,7 @@ const LineageView: FC = ({ lineageResource, entityType }) => { type: 'sankey', }, tooltip: { - trigger: 'item', - triggerOn: 'mousemove', + show: false, }, }; }, [lineageResource, entityType, theme]); @@ -297,6 +587,20 @@ const LineageView: FC = ({ lineageResource, entityType }) => { return ; } + // Helper function to get the URL for an entity + const getEntityUrl = (nodeDetails: NodeDetails): string => { + switch (nodeDetails.type) { + case 'dashboard': + return `/superset/dashboard/${nodeDetails.id}/`; + case 'chart': + return `/explore/?slice_id=${nodeDetails.id}`; + case 'dataset': + return `/dataset/${nodeDetails.id}`; + default: + return '#'; + } + }; + return ( @@ -308,11 +612,64 @@ const LineageView: FC = ({ lineageResource, entityType }) => { + {selectedNode && ( + + + + {selectedNode.type.charAt(0).toUpperCase() + selectedNode.type.slice(1)} Details + + + {(selectedNode.type === 'dashboard' || selectedNode.type === 'chart') && ( + + )} + + + + + + {t('Name')}: + {selectedNode.name} + + {selectedNode.id && ( + + {t('ID')}: + {selectedNode.id} + + )} + {selectedNode.additionalInfo && + Object.entries(selectedNode.additionalInfo).map(([key, value]) => ( + + + {key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ')}: + + {String(value)} + + ))} + + + )} ); };