From 1dcd33a084122bd91650ab6ea6b9ddd14fa5cc75 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Thu, 16 Jul 2026 15:56:35 -0400 Subject: [PATCH 1/2] Add Asset Tab plugin --- .../administration-and-deployment/plugins.rst | 8 ++-- .../core_api/datamodels/plugins.py | 2 +- .../openapi/v2-rest-api-generated.yaml | 2 + .../ui/openapi-gen/requests/schemas.gen.ts | 4 +- .../ui/openapi-gen/requests/types.gen.ts | 8 ++-- .../ui/src/pages/Asset/AssetLayout.tsx | 4 ++ .../src/airflow/ui/src/pages/Iframe.tsx | 17 ++++++- .../src/airflow/ui/src/pages/ReactPlugin.tsx | 34 ++++++++++++-- airflow-core/src/airflow/ui/src/router.tsx | 1 + .../core_api/datamodels/test_plugins.py | 46 +++++++++++++++++++ .../airflowctl/api/datamodels/generated.py | 2 + 11 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst b/airflow-core/docs/administration-and-deployment/plugins.rst index d8952d74d03c8..2766da35bb130 100644 --- a/airflow-core/docs/administration-and-deployment/plugins.rst +++ b/airflow-core/docs/administration-and-deployment/plugins.rst @@ -237,10 +237,10 @@ definitions in Airflow. # Name of the external view, this will be displayed in the UI. "name": "Name of the External View", # Source URL of the external view. This URL can be templated using context variables, depending on the location where the external view is rendered - # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX). + # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX, ASSET_ID, ASSET_URI). "href": "https://example.com/{DAG_ID}/{RUN_ID}/{TASK_ID}/{MAP_INDEX}", # Destination of the external view. This is used to determine where the view will be loaded in the UI. - # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance", "base"], default to "nav". + # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance", "asset", "base"], default to "nav". "destination": "dag_run", # Optional icon, url to an svg file. "icon": "https://example.com/icon.svg", @@ -265,10 +265,10 @@ definitions in Airflow. "name": "Name of the React App", # Bundle URL of the React app. This is the URL where the React app is served from. It can be a static file or a CDN. # This URL can be templated using context variables, depending on the location where the external view is rendered - # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX). + # the context variables available will be different, i.e a subset of (DAG_ID, RUN_ID, TASK_ID, MAP_INDEX, ASSET_ID, ASSET_URI). "bundle_url": "https://example.com/static/js/my_react_app.js", # Destination of the react app. This is used to determine where the app will be loaded in the UI. - # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance", "base"], default to "nav". + # Supported locations are Literal["nav", "dag", "dag_run", "task", "task_instance", "asset", "base"], default to "nav". # It can also be put inside of an existing page, the supported views are ["dashboard", "dag_overview", "task_overview"]. You can position # element in the existing page via the css `order` rule which will determine the flex order. # Use "base" to mount the app in the base layout (e.g. a toolbar strip); the host uses a flex container so you can set ``order`` in your root JSX to control position. diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py index 2bddb29ac9672..fb8859481d110 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py @@ -69,7 +69,7 @@ class AppBuilderMenuItemResponse(BaseModel): category: str | None = None -BaseDestinationLiteral = Literal["nav", "dag", "dag_run", "task", "task_instance", "base"] +BaseDestinationLiteral = Literal["nav", "dag", "dag_run", "task", "task_instance", "asset", "base"] class BaseUIResponse(BaseModel): diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml index 005da0c6f4b5e..e203e33087ecb 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml @@ -14768,6 +14768,7 @@ components: - dag_run - task - task_instance + - asset - base title: Destination default: nav @@ -15724,6 +15725,7 @@ components: - dag_run - task - task_instance + - asset - base - dashboard title: Destination diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index e1880fdd4ea89..b150aee92d694 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -4878,7 +4878,7 @@ export const $ExternalViewResponse = { }, destination: { type: 'string', - enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'base'], + enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'asset', 'base'], title: 'Destination', default: 'nav' } @@ -6270,7 +6270,7 @@ export const $ReactAppResponse = { }, destination: { type: 'string', - enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'base', 'dashboard'], + enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'asset', 'base', 'dashboard'], title: 'Destination', default: 'nav' } diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 24b1fbe5f2e9b..b846ed24c3edd 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -1280,11 +1280,11 @@ export type ExternalViewResponse = { category?: string | null; nav_top_level?: boolean | null; href: string; - destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base'; + destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'asset' | 'base'; [key: string]: unknown | string; }; -export type destination = 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base'; +export type destination = 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'asset' | 'base'; /** * Extra Links Response. @@ -1655,11 +1655,11 @@ export type ReactAppResponse = { category?: string | null; nav_top_level?: boolean | null; bundle_url: string; - destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base' | 'dashboard'; + destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'asset' | 'base' | 'dashboard'; [key: string]: unknown | string; }; -export type destination2 = 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'base' | 'dashboard'; +export type destination2 = 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 'asset' | 'base' | 'dashboard'; /** * Internal enum for setting reprocess behavior in a backfill. diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx index ade558468cd2d..7a1326b11b15c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx @@ -28,6 +28,7 @@ import { useAssetServiceGetAsset } from "openapi/queries"; import { BreadcrumbStats } from "src/components/BreadcrumbStats"; import { ProgressBar } from "src/components/ui"; import { GroupsProvider } from "src/context/groups"; +import { usePluginTabs } from "src/hooks/usePluginTabs"; import { NavTabs } from "src/layouts/Details/NavTabs"; import { useDocumentTitle } from "src/utils"; @@ -62,6 +63,8 @@ export const AssetLayout = () => { const { fitView, getZoom } = useReactFlow(); + const externalTabs = usePluginTabs("asset"); + const tabs = [ { icon: , label: translate("assets:events"), value: "" }, { @@ -69,6 +72,7 @@ export const AssetLayout = () => { label: translate("assets:assetStateStore.title"), value: "asset-state-store", }, + ...externalTabs, ]; return ( diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx index 6d3f5152c2048..f93b737281747 100644 --- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx @@ -18,6 +18,7 @@ */ import { useParams } from "react-router-dom"; +import { useAssetServiceGetAsset } from "openapi/queries"; import type { ExternalViewResponse } from "openapi/requests/types.gen"; export const Iframe = ({ @@ -27,7 +28,15 @@ export const Iframe = ({ readonly externalView: ExternalViewResponse; readonly sandbox?: string; }) => { - const { dagId, mapIndex, runId, taskId } = useParams(); + const { assetId, dagId, mapIndex, runId, taskId } = useParams(); + + // The asset URI is not part of the route, so resolve it from the asset record. This is a + // cache hit because the asset details page has already fetched it. + const { data: asset } = useAssetServiceGetAsset( + { assetId: assetId === undefined ? 0 : parseInt(assetId, 10) }, + undefined, + { enabled: Boolean(assetId) }, + ); // Build the href URL with context parameters if the view has a destination let src = externalView.href; @@ -46,6 +55,12 @@ export const Iframe = ({ if (mapIndex !== undefined) { src = src.replaceAll("{MAP_INDEX}", mapIndex); } + if (assetId !== undefined) { + src = src.replaceAll("{ASSET_ID}", encodeURIComponent(assetId)); + } + if (asset?.uri !== undefined) { + src = src.replaceAll("{ASSET_URI}", encodeURIComponent(asset.uri)); + } } if (src.startsWith("http://") || src.startsWith("https://")) { diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx index d78198be9ea81..402dddeeceae2 100644 --- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx +++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx @@ -20,11 +20,14 @@ import { Spinner } from "@chakra-ui/react"; import { type FC, lazy, Suspense } from "react"; import { useParams } from "react-router-dom"; +import { useAssetServiceGetAsset } from "openapi/queries"; import type { ReactAppResponse } from "openapi/requests/types.gen"; import { ErrorPage } from "./Error"; export type PluginProps = { + assetId?: string; + assetUri?: string; dagId?: string; mapIndex?: string; runId?: string; @@ -62,7 +65,16 @@ const loadPlugin = (reactApp: ReactAppResponse): Promise<{ default: PluginCompon }); export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse }) => { - const { dagId, mapIndex, runId, taskId } = useParams(); + const { assetId, dagId, mapIndex, runId, taskId } = useParams(); + + // The asset URI is not part of the route, so resolve it from the asset record. This is a + // cache hit because the asset details page has already fetched it. + const { data: asset } = useAssetServiceGetAsset( + { assetId: assetId === undefined ? 0 : parseInt(assetId, 10) }, + undefined, + { enabled: Boolean(assetId) }, + ); + const assetUri = asset?.uri; // If the plugin component was already registered on the global object by a previous load, // render it directly without going through Suspense/lazy (avoids flashing the spinner). @@ -71,7 +83,16 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse if (typeof existing === "function") { const Plugin = existing as PluginComponentType; - return ; + return ( + + ); } // Otherwise, lazy-load the bundle once. When it resolves, it must set a function component @@ -80,7 +101,14 @@ export const ReactPlugin = ({ reactApp }: { readonly reactApp: ReactAppResponse return ( }> - + ); }; diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx index d0e5ad2565807..26a982dd7328b 100644 --- a/airflow-core/src/airflow/ui/src/router.tsx +++ b/airflow-core/src/airflow/ui/src/router.tsx @@ -137,6 +137,7 @@ export const routerConfig = [ children: [ { element: , index: true }, { element: , path: "asset-state-store" }, + pluginRoute, ], element: , path: "assets/:assetId", diff --git a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py new file mode 100644 index 0000000000000..1036f0efe3d39 --- /dev/null +++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py @@ -0,0 +1,46 @@ +# 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. +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from airflow.api_fastapi.core_api.datamodels.plugins import ExternalViewResponse, ReactAppResponse + + +class TestUIPluginDestination: + @pytest.mark.parametrize( + "destination", + ["nav", "dag", "dag_run", "task", "task_instance", "asset", "base"], + ) + def test_external_view_accepts_destination(self, destination): + view = ExternalViewResponse(name="Asset View", href="https://example.com", destination=destination) + assert view.destination == destination + + @pytest.mark.parametrize( + "destination", + ["nav", "dag", "dag_run", "task", "task_instance", "asset", "base", "dashboard"], + ) + def test_react_app_accepts_destination(self, destination): + app = ReactAppResponse( + name="Asset App", bundle_url="https://example.com/app.js", destination=destination + ) + assert app.destination == destination + + def test_external_view_rejects_unknown_destination(self): + with pytest.raises(ValidationError): + ExternalViewResponse(name="Bad View", href="https://example.com", destination="assets") diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py b/airflow-ctl/src/airflowctl/api/datamodels/generated.py index 9bb75d8dd87ce..ff1297f5c7207 100644 --- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py +++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py @@ -695,6 +695,7 @@ class Destination(str, Enum): DAG_RUN = "dag_run" TASK = "task" TASK_INSTANCE = "task_instance" + ASSET = "asset" BASE = "base" @@ -931,6 +932,7 @@ class Destination1(str, Enum): DAG_RUN = "dag_run" TASK = "task" TASK_INSTANCE = "task_instance" + ASSET = "asset" BASE = "base" DASHBOARD = "dashboard" From 34477fffc64d983a1a4c483ece1b98533f781e91 Mon Sep 17 00:00:00 2001 From: Brent Bovenzi Date: Mon, 20 Jul 2026 11:44:32 -0400 Subject: [PATCH 2/2] Delete test_plugins.py --- .../core_api/datamodels/test_plugins.py | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py diff --git a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py deleted file mode 100644 index 1036f0efe3d39..0000000000000 --- a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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. -from __future__ import annotations - -import pytest -from pydantic import ValidationError - -from airflow.api_fastapi.core_api.datamodels.plugins import ExternalViewResponse, ReactAppResponse - - -class TestUIPluginDestination: - @pytest.mark.parametrize( - "destination", - ["nav", "dag", "dag_run", "task", "task_instance", "asset", "base"], - ) - def test_external_view_accepts_destination(self, destination): - view = ExternalViewResponse(name="Asset View", href="https://example.com", destination=destination) - assert view.destination == destination - - @pytest.mark.parametrize( - "destination", - ["nav", "dag", "dag_run", "task", "task_instance", "asset", "base", "dashboard"], - ) - def test_react_app_accepts_destination(self, destination): - app = ReactAppResponse( - name="Asset App", bundle_url="https://example.com/app.js", destination=destination - ) - assert app.destination == destination - - def test_external_view_rejects_unknown_destination(self): - with pytest.raises(ValidationError): - ExternalViewResponse(name="Bad View", href="https://example.com", destination="assets")