From 1dcd33a084122bd91650ab6ea6b9ddd14fa5cc75 Mon Sep 17 00:00:00 2001
From: Brent Bovenzi <brent.bovenzi@gmail.com>
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: <MdTimeline />, 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 <Plugin dagId={dagId} mapIndex={mapIndex} runId={runId} taskId={taskId} />;
+    return (
+      <Plugin
+        assetId={assetId}
+        assetUri={assetUri}
+        dagId={dagId}
+        mapIndex={mapIndex}
+        runId={runId}
+        taskId={taskId}
+      />
+    );
   }
 
   // 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 (
     <Suspense fallback={<Spinner />}>
-      <LazyPlugin dagId={dagId} mapIndex={mapIndex} runId={runId} taskId={taskId} />
+      <LazyPlugin
+        assetId={assetId}
+        assetUri={assetUri}
+        dagId={dagId}
+        mapIndex={mapIndex}
+        runId={runId}
+        taskId={taskId}
+      />
     </Suspense>
   );
 };
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: <AssetEvents />, index: true },
           { element: <AssetStateStore />, path: "asset-state-store" },
+          pluginRoute,
         ],
         element: <Asset />,
         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 <brent.bovenzi@gmail.com>
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")
