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 fc116c39ffb AIP-68 - Add iframe plugins as tabs on dag pages (#52795)
fc116c39ffb is described below
commit fc116c39ffb2d98ecfd84012ef079f48e4d3a7ce
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Jul 8 17:31:01 2025 -0400
AIP-68 - Add iframe plugins as tabs on dag pages (#52795)
* Add iframes plugins as Tabs on dag pages
* Cleanup code after PR feedback
---
.../src/airflow/ui/src/hooks/usePluginTabs.tsx | 64 ++++++++++++++++++++++
.../ui/src/layouts/Details/DetailsLayout.tsx | 2 +-
airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx | 5 ++
airflow-core/src/airflow/ui/src/pages/Iframe.tsx | 37 +++++++++++--
airflow-core/src/airflow/ui/src/pages/Run/Run.tsx | 5 ++
.../src/airflow/ui/src/pages/Task/Task.tsx | 5 ++
.../ui/src/pages/TaskInstance/TaskInstance.tsx | 5 ++
airflow-core/src/airflow/ui/src/router.tsx | 25 ++++++---
8 files changed, 134 insertions(+), 14 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
new file mode 100644
index 00000000000..a20b7fc3a04
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
@@ -0,0 +1,64 @@
+/*!
+ * 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.
+ */
+import type { ReactNode } from "react";
+import { LuPlug } from "react-icons/lu";
+
+import { usePluginServiceGetPlugins } from "openapi/queries";
+import type { ExternalViewResponse } from "openapi/requests/types.gen";
+import { useColorMode } from "src/context/colorMode";
+
+type TabPlugin = {
+ icon: ReactNode;
+ label: string;
+ value: string;
+};
+
+export const usePluginTabs = (destination: string): Array<TabPlugin> => {
+ const { colorMode } = useColorMode();
+ const { data: pluginData } = usePluginServiceGetPlugins();
+
+ // Get external views with the specified destination and ensure they have
url_route
+ const externalViews =
+ pluginData?.plugins
+ .flatMap((plugin) => plugin.external_views)
+ .filter((view: ExternalViewResponse) => view.destination === destination
&& Boolean(view.url_route)) ??
+ [];
+
+ return externalViews.map((view) => {
+ // Choose icon based on theme - prefer dark mode icon if available and in
dark mode
+ let iconSrc = view.icon;
+
+ if (colorMode === "dark" && view.icon_dark_mode !== undefined &&
view.icon_dark_mode !== null) {
+ iconSrc = view.icon_dark_mode;
+ }
+
+ const icon =
+ iconSrc !== undefined && iconSrc !== null ? (
+ <img alt={view.name} src={iconSrc} style={{ height: "1rem", width:
"1rem" }} />
+ ) : (
+ <LuPlug />
+ );
+
+ return {
+ icon,
+ label: view.name,
+ value: `plugin/${view.url_route}`,
+ };
+ });
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index a7c922454a0..a1dba8f668a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -180,7 +180,7 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
) : undefined}
<ProgressBar size="xs" visibility={isLoading ? "visible" :
"hidden"} />
<NavTabs tabs={tabs} />
- <Box h="100%" overflow="auto" px={2}>
+ <Box flexGrow={1} overflow="auto" px={2}>
<Outlet />
</Box>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
index cca182140b8..877f57ff795 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx
@@ -28,6 +28,7 @@ import { useParams } from "react-router-dom";
import { useDagServiceGetDagDetails, useDagServiceGetDagsUi } from
"openapi/queries";
import type { DAGWithLatestDagRunsResponse } from "openapi/requests/types.gen";
import { TaskIcon } from "src/assets/TaskIcon";
+import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useRefreshOnNewDagRuns } from "src/queries/useRefreshOnNewDagRuns";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -38,6 +39,9 @@ export const Dag = () => {
const { t: translate } = useTranslation("dag");
const { dagId = "" } = useParams();
+ // Get external views with dag destination
+ const externalTabs = usePluginTabs("dag");
+
const tabs = [
{ icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
{ icon: <FiBarChart />, label: translate("tabs.runs"), value: "runs" },
@@ -46,6 +50,7 @@ export const Dag = () => {
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
{ icon: <MdDetails />, label: translate("tabs.details"), value: "details"
},
+ ...externalTabs,
];
const {
diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
index bac9843ce85..91b804ed550 100644
--- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
@@ -27,7 +27,7 @@ import { ErrorPage } from "./Error";
export const Iframe = ({ sandbox = "allow-same-origin allow-forms" }: {
readonly sandbox: string }) => {
const { t: translate } = useTranslation();
- const { page } = useParams();
+ const { dagId, mapIndex, page, runId, taskId } = useParams();
const { data: pluginData, isLoading } = usePluginServiceGetPlugins();
const iframeView =
@@ -49,12 +49,41 @@ export const Iframe = ({ sandbox = "allow-same-origin
allow-forms" }: { readonly
return <ErrorPage />;
}
+ // Build the href URL with context parameters if the view has a destination
+ let src = iframeView.href;
+
+ if (iframeView.destination !== undefined && iframeView.destination !==
"nav") {
+ // Check if the href contains placeholders that need to be replaced
+ if (dagId !== undefined) {
+ src = src.replaceAll("{DAG_ID}", dagId);
+ }
+ if (runId !== undefined) {
+ src = src.replaceAll("{RUN_ID}", runId);
+ }
+ if (taskId !== undefined) {
+ src = src.replaceAll("{TASK_ID}", taskId);
+ }
+ if (mapIndex !== undefined) {
+ src = src.replaceAll("{MAP_INDEX}", mapIndex);
+ }
+ }
+
return (
- <Box flexGrow={1} m={-3}>
+ <Box
+ flexGrow={1}
+ height="100%"
+ m={-2} // Compensate for parent padding
+ minHeight={0}
+ >
<iframe
sandbox={sandbox}
- src={iframeView.href}
- style={{ height: "100%", width: "100%" }}
+ src={src}
+ style={{
+ border: "none",
+ display: "block",
+ height: "100%",
+ width: "100%",
+ }}
title={iframeView.name}
/>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index 0b9d8c9db94..c9326497b3f 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -23,6 +23,7 @@ import { MdDetails, MdOutlineEventNote, MdOutlineTask } from
"react-icons/md";
import { useParams } from "react-router-dom";
import { useDagRunServiceGetDagRun, useDagServiceGetDagDetails } from
"openapi/queries";
+import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -32,12 +33,16 @@ export const Run = () => {
const { t: translate } = useTranslation("dag");
const { dagId = "", runId = "" } = useParams();
+ // Get external views with dag_run destination
+ const externalTabs = usePluginTabs("dag_run");
+
const tabs = [
{ icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value:
"" },
{ icon: <FiDatabase />, label: translate("tabs.assetEvents"), value:
"asset_events" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
{ icon: <MdDetails />, label: translate("tabs.details"), value: "details"
},
+ ...externalTabs,
];
const refetchInterval = useAutoRefresh({ dagId });
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
index e082c8dfe8e..c832c620434 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -23,6 +23,7 @@ import { MdOutlineEventNote, MdOutlineTask } from
"react-icons/md";
import { useParams } from "react-router-dom";
import { useDagServiceGetDagDetails, useTaskServiceGetTask } from
"openapi/queries";
+import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { useGridStructure } from "src/queries/useGridStructure.ts";
import { getGroupTask } from "src/utils/groupTask";
@@ -34,10 +35,14 @@ export const Task = () => {
const { t: translate } = useTranslation("dag");
const { dagId = "", groupId, taskId } = useParams();
+ // Get external views with task destination
+ const externalTabs = usePluginTabs("task");
+
const tabs = [
{ icon: <LuChartColumn />, label: translate("tabs.overview"), value: "" },
{ icon: <MdOutlineTask />, label: translate("tabs.taskInstances"), value:
"task_instances" },
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
+ ...externalTabs,
];
const displayTabs = groupId === undefined ? tabs : tabs.filter((tab) =>
tab.value !== "events");
diff --git
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
index c7fa69d6811..83c6ae7e47d 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -28,6 +28,7 @@ import {
useGridServiceGridData,
useTaskInstanceServiceGetMappedTaskInstance,
} from "openapi/queries";
+import { usePluginTabs } from "src/hooks/usePluginTabs";
import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -37,6 +38,9 @@ export const TaskInstance = () => {
const { t: translate } = useTranslation("dag");
const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
+ // Get external views with task_instance destination
+ const externalTabs = usePluginTabs("task_instance");
+
const tabs = [
{ icon: <MdReorder />, label: translate("tabs.logs"), value: "" },
{
@@ -49,6 +53,7 @@ export const TaskInstance = () => {
{ icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value:
"events" },
{ icon: <FiCode />, label: translate("tabs.code"), value: "code" },
{ icon: <MdDetails />, label: translate("tabs.details"), value: "details"
},
+ ...externalTabs,
];
const refetchInterval = useAutoRefresh({ dagId });
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index dd23359fb90..4c38a112d32 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -60,6 +60,16 @@ import { XCom } from "src/pages/XCom";
import { client } from "./queryClient";
+const iframeRoute = {
+ // The following iframe sandbox setting is intentionally less restrictive.
+ // This is considered safe because the framed content originates from the
Plugins,
+ // which is part of the deployment of Airflow and trusted as per our
security policy.
+ //
https://airflow.apache.org/docs/apache-airflow/stable/security/security_model.html
+ // They are not user provided plugins.
+ element: <Iframe sandbox="allow-scripts allow-same-origin allow-forms" />,
+ path: "plugin/:page",
+};
+
const taskInstanceRoutes = [
{ element: <Logs />, index: true },
{ element: <Events />, path: "events" },
@@ -69,6 +79,7 @@ const taskInstanceRoutes = [
{ element: <RenderedTemplates />, path: "rendered_templates" },
{ element: <TaskInstances />, path: "task_instances" },
{ element: <TaskInstanceAssetEvents />, path: "asset_events" },
+ iframeRoute,
];
export const routerConfig = [
@@ -142,15 +153,7 @@ export const routerConfig = [
element: <Connections />,
path: "connections",
},
- {
- // The following iframe sandbox setting is intentionally less
restrictive.
- // This is considered safe because the framed content originates from
the Plugins,
- // which is part of the deployment of Airflow and trusted as per our
security policy.
- //
https://airflow.apache.org/docs/apache-airflow/stable/security/security_model.html
- // They are not user provided plugins.
- element: <Iframe sandbox="allow-scripts allow-same-origin allow-forms"
/>,
- path: "plugin/:page",
- },
+ iframeRoute,
{
children: [
{ element: <Overview />, index: true },
@@ -160,6 +163,7 @@ export const routerConfig = [
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
{ element: <DagDetails />, path: "details" },
+ iframeRoute,
],
element: <Dag />,
path: "dags/:dagId",
@@ -171,6 +175,7 @@ export const routerConfig = [
{ element: <Code />, path: "code" },
{ element: <DagRunDetails />, path: "details" },
{ element: <DagRunAssetEvents />, path: "asset_events" },
+ iframeRoute,
],
element: <Run />,
path: "dags/:dagId/runs/:runId",
@@ -194,6 +199,7 @@ export const routerConfig = [
children: [
{ element: <TaskOverview />, index: true },
{ element: <TaskInstances />, path: "task_instances" },
+ iframeRoute,
],
element: <Task />,
path: "dags/:dagId/tasks/group/:groupId",
@@ -208,6 +214,7 @@ export const routerConfig = [
{ element: <TaskOverview />, index: true },
{ element: <TaskInstances />, path: "task_instances" },
{ element: <Events />, path: "events" },
+ iframeRoute,
],
element: <Task />,
path: "dags/:dagId/tasks/:taskId",