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"