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

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4e01f405a6a Add Asset Tab plugin (#69986)
4e01f405a6a is described below

commit 4e01f405a6a038dae9e856c40c0946a43ab609d8
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Jul 20 13:00:27 2026 -0400

    Add Asset Tab plugin (#69986)
    
    * Add Asset Tab plugin
    
    * Delete test_plugins.py
---
 .../docs/administration-and-deployment/plugins.rst |  8 ++---
 .../api_fastapi/core_api/datamodels/plugins.py     |  2 +-
 .../core_api/openapi/v2-rest-api-generated.yaml    |  2 ++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  4 +--
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  8 ++---
 .../src/airflow/ui/src/pages/Asset/AssetLayout.tsx |  4 +++
 airflow-core/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 +
 .../src/airflowctl/api/datamodels/generated.py     |  2 ++
 10 files changed, 67 insertions(+), 15 deletions(-)

diff --git a/airflow-core/docs/administration-and-deployment/plugins.rst 
b/airflow-core/docs/administration-and-deployment/plugins.rst
index d8952d74d03..2766da35bb1 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 ed2e9d6a86f..dd68ede22c9 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 4d6be859a13..d1a6ee83fd0 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
@@ -14936,6 +14936,7 @@ components:
           - dag_run
           - task
           - task_instance
+          - asset
           - base
           title: Destination
           default: nav
@@ -15897,6 +15898,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 7c56619e0a4..e279a291f8b 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
@@ -4980,7 +4980,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'
         }
@@ -6383,7 +6383,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 f43c5708a08..3bbc72ebc95 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
@@ -1303,11 +1303,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.
@@ -1679,11 +1679,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 ade558468cd..7a1326b11b1 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 6d3f5152c20..f93b7372817 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 d78198be9ea..402dddeecea 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 d0e5ad25658..26a982dd732 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-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index b4d61266d16..a83b8c7bb7a 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"
 

Reply via email to