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

Reply via email to