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-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"