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 d169a6ba672 Add react apps plugins to the UI (#53377)
d169a6ba672 is described below

commit d169a6ba6727b56721e4dbc6e957c404fbc1245e
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Jul 16 12:02:57 2025 -0400

    Add react apps plugins to the UI (#53377)
    
    * Add react apps plugins to the UI
    
    * Consolidate types
    
    * Sanitize plugin urls
    
    * PR feedback
    
    * Fix comments
---
 .../src/airflow/ui/src/hooks/usePluginTabs.tsx     | 10 ++-
 .../src/airflow/ui/src/layouts/Nav/AdminButton.tsx |  5 +-
 .../airflow/ui/src/layouts/Nav/BrowseButton.tsx    |  5 +-
 .../src/airflow/ui/src/layouts/Nav/DocsButton.tsx  |  4 +-
 .../src/airflow/ui/src/layouts/Nav/Nav.tsx         | 98 ++++++++++++++++++----
 .../airflow/ui/src/layouts/Nav/PluginMenuItem.tsx  |  9 +-
 .../src/airflow/ui/src/layouts/Nav/PluginMenus.tsx | 69 ++++-----------
 .../ui/src/layouts/Nav/UserSettingsButton.tsx      |  8 +-
 .../src/airflow/ui/src/pages/ExternalView.tsx      | 91 ++++++++++++++++++++
 airflow-core/src/airflow/ui/src/pages/Iframe.tsx   | 71 +++++-----------
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       | 46 ++++++++++
 airflow-core/src/airflow/ui/src/router.tsx         | 23 ++---
 airflow-core/src/airflow/ui/src/utils/types.ts     | 22 +++++
 13 files changed, 313 insertions(+), 148 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx 
b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
index a20b7fc3a04..0bde4e06e39 100644
--- a/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/usePluginTabs.tsx
@@ -20,7 +20,7 @@ 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 type { ExternalViewResponse, ReactAppResponse } from 
"openapi/requests/types.gen";
 import { useColorMode } from "src/context/colorMode";
 
 type TabPlugin = {
@@ -36,9 +36,11 @@ export const usePluginTabs = (destination: string): 
Array<TabPlugin> => {
   // 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)) ??
-    [];
+      .flatMap((plugin) => [...plugin.external_views, ...plugin.react_apps])
+      .filter(
+        (view: ExternalViewResponse | ReactAppResponse) =>
+          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
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
index 383a880d3d2..349a270e886 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
@@ -20,8 +20,9 @@ import { useTranslation } from "react-i18next";
 import { FiSettings } from "react-icons/fi";
 import { Link as RouterLink } from "react-router-dom";
 
-import type { MenuItem, ExternalViewResponse } from 
"openapi/requests/types.gen";
+import type { MenuItem } from "openapi/requests/types.gen";
 import { Menu } from "src/components/ui";
+import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
@@ -58,7 +59,7 @@ export const AdminButton = ({
   externalViews,
 }: {
   readonly authorizedMenuItems: Array<MenuItem>;
-  readonly externalViews: Array<ExternalViewResponse>;
+  readonly externalViews: Array<NavItemResponse>;
 }) => {
   const { t: translate } = useTranslation("common");
   const menuItems = links
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index 09b986a55f7..3df43b9fbac 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -20,8 +20,9 @@ import { useTranslation } from "react-i18next";
 import { FiGlobe } from "react-icons/fi";
 import { Link as RouterLink } from "react-router-dom";
 
-import type { MenuItem, ExternalViewResponse } from 
"openapi/requests/types.gen";
+import type { MenuItem } from "openapi/requests/types.gen";
 import { Menu } from "src/components/ui";
+import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
@@ -44,7 +45,7 @@ export const BrowseButton = ({
   externalViews,
 }: {
   readonly authorizedMenuItems: Array<MenuItem>;
-  readonly externalViews: Array<ExternalViewResponse>;
+  readonly externalViews: Array<NavItemResponse>;
 }) => {
   const { t: translate } = useTranslation("common");
   const menuItems = links
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
index 6044b0e448c..a33cc3724e9 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
@@ -20,9 +20,9 @@ import { Link } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { FiBookOpen, FiExternalLink } from "react-icons/fi";
 
-import type { ExternalViewResponse } from "openapi/requests/types.gen";
 import { Menu } from "src/components/ui";
 import { useConfig } from "src/queries/useConfig";
+import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
@@ -49,7 +49,7 @@ export const DocsButton = ({
   showAPI,
   version,
 }: {
-  readonly externalViews: Array<ExternalViewResponse>;
+  readonly externalViews: Array<NavItemResponse>;
   readonly showAPI?: boolean;
   readonly version?: string;
 }) => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
index 7f3046a7a70..b7d4edc2ca3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -26,8 +26,10 @@ import {
   useVersionServiceGetVersion,
   usePluginServiceGetPlugins,
 } from "openapi/queries";
+import type { ExternalViewResponse } from "openapi/requests/types.gen";
 import { AirflowPin } from "src/assets/AirflowPin";
 import { DagIcon } from "src/assets/DagIcon";
+import type { NavItemResponse } from "src/utils/types";
 
 import { AdminButton } from "./AdminButton";
 import { BrowseButton } from "./BrowseButton";
@@ -37,23 +39,91 @@ import { PluginMenus } from "./PluginMenus";
 import { SecurityButton } from "./SecurityButton";
 import { UserSettingsButton } from "./UserSettingsButton";
 
+// Define existing button categories to filter out
+const existingCategories = ["user", "docs", "admin", "browse"];
+
+// Function to categorize navigation items in a single pass
+const categorizeNavItems = (
+  items: Array<NavItemResponse>,
+): {
+  adminItems: Array<NavItemResponse>;
+  browseItems: Array<NavItemResponse>;
+  docsItems: Array<NavItemResponse>;
+  topNavItems: Array<NavItemResponse>;
+  userItems: Array<NavItemResponse>;
+} => {
+  const adminItems: Array<NavItemResponse> = [];
+  const browseItems: Array<NavItemResponse> = [];
+  const docsItems: Array<NavItemResponse> = [];
+  const topNavItems: Array<NavItemResponse> = [];
+  const userItems: Array<NavItemResponse> = [];
+
+  items.forEach((item) => {
+    const category = item.category?.toLowerCase();
+
+    // Categorize items for specific buttons
+    if (category === "browse") {
+      browseItems.push(item);
+    } else if (category === "admin") {
+      adminItems.push(item);
+    } else if (category === "docs") {
+      docsItems.push(item);
+    } else if (category === "user") {
+      userItems.push(item);
+    }
+
+    // Add to top nav items if not in existing categories
+    if (category === undefined || !existingCategories.includes(category)) {
+      topNavItems.push(item);
+    }
+  });
+
+  return {
+    adminItems,
+    browseItems,
+    docsItems,
+    topNavItems,
+    userItems,
+  };
+};
+
 export const Nav = () => {
   const { data } = useVersionServiceGetVersion();
   const { data: authLinks } = useAuthLinksServiceGetAuthMenus();
   const { data: pluginData } = usePluginServiceGetPlugins();
   const { t: translate } = useTranslation("common");
 
-  // Get external views with nav destination
-  const navExternalViews =
+  // Get both external views and react apps with nav destination
+  const navItems: Array<NavItemResponse> =
     pluginData?.plugins
-      .flatMap((plugin) => plugin.external_views)
-      .filter((view) => view.destination === "nav") ?? [];
+      .flatMap((plugin) => [...plugin.external_views, ...plugin.react_apps])
+      .filter((item) => item.destination === "nav") ?? [];
+
+  // Categorize all navigation items in a single pass
+  const { adminItems, browseItems, docsItems, topNavItems, userItems } = 
categorizeNavItems(navItems);
+
+  // Check for legacy views
+  const hasLegacyViews =
+    (
+      pluginData?.plugins
+        .flatMap((plugin) => plugin.appbuilder_views)
+        // Only include legacy views that have a visible link in the menu. No 
menu items views
+        // are accessible via direct URLs.
+        .filter((view) => typeof view.name === "string" && view.name.length > 
0) ?? []
+    ).length >= 1;
 
-  // Categorize external views by their category
-  const browseViews = navExternalViews.filter((view) => 
view.category?.toLowerCase() === "browse");
-  const adminViews = navExternalViews.filter((view) => 
view.category?.toLowerCase() === "admin");
-  const docsViews = navExternalViews.filter((view) => 
view.category?.toLowerCase() === "docs");
-  const userViews = navExternalViews.filter((view) => 
view.category?.toLowerCase() === "user");
+  // Add legacy views if they exist
+  const navItemsWithLegacy = hasLegacyViews
+    ? [
+        ...topNavItems,
+        {
+          destination: "nav",
+          href: "/pluginsv2",
+          name: translate("nav.legacyFabViews"),
+          url_route: "legacy-fab-views",
+        } as ExternalViewResponse,
+      ]
+    : topNavItems;
 
   return (
     <VStack
@@ -96,22 +166,22 @@ export const Nav = () => {
         />
         <BrowseButton
           authorizedMenuItems={authLinks?.authorized_menu_items ?? []}
-          externalViews={browseViews}
+          externalViews={browseItems}
         />
         <AdminButton
           authorizedMenuItems={authLinks?.authorized_menu_items ?? []}
-          externalViews={adminViews}
+          externalViews={adminItems}
         />
         <SecurityButton />
-        <PluginMenus />
+        <PluginMenus navItems={navItemsWithLegacy} />
       </Flex>
       <Flex flexDir="column">
         <DocsButton
-          externalViews={docsViews}
+          externalViews={docsItems}
           showAPI={authLinks?.authorized_menu_items.includes("Docs")}
           version={data?.version}
         />
-        <UserSettingsButton externalViews={userViews} />
+        <UserSettingsButton externalViews={userItems} />
       </Flex>
     </VStack>
   );
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx
index 429c41c977d..8629aa3311f 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenuItem.tsx
@@ -23,12 +23,17 @@ import { RiArchiveStackLine } from "react-icons/ri";
 import { Link as RouterLink } from "react-router-dom";
 
 import type { ExternalViewResponse } from "openapi/requests/types.gen";
+import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
 
-type Props = { readonly topLevel?: boolean } & ExternalViewResponse;
+type Props = { readonly topLevel?: boolean } & NavItemResponse;
+
+export const PluginMenuItem = ({ icon, name, topLevel = false, url_route: 
urlRoute, ...rest }: Props) => {
+  // Determine if this is an external view or react app based on the presence 
of href
+  const isExternalView = "href" in rest;
+  const href = isExternalView ? (rest as ExternalViewResponse).href : 
undefined;
 
-export const PluginMenuItem = ({ href, icon, name, topLevel = false, 
url_route: urlRoute }: Props) => {
   const pluginIcon =
     typeof icon === "string" ? (
       <Image height="1.25rem" mr={topLevel ? 0 : 2} src={icon} width="1.25rem" 
/>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
index b8b1833930d..bb0ddfc506c 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
@@ -21,80 +21,43 @@ import { useTranslation } from "react-i18next";
 import { FiChevronRight } from "react-icons/fi";
 import { LuPlug } from "react-icons/lu";
 
-import { usePluginServiceGetPlugins } from "openapi/queries";
-import type { ExternalViewResponse } from "openapi/requests/types.gen";
 import { Menu } from "src/components/ui";
+import type { NavItemResponse } from "src/utils/types";
 
 import { NavButton } from "./NavButton";
 import { PluginMenuItem } from "./PluginMenuItem";
 
-// Define existing button categories to filter out
-const existingCategories = ["user", "docs", "admin", "browse"];
-
-export const PluginMenus = () => {
+export const PluginMenus = ({ navItems }: { readonly navItems: 
Array<NavItemResponse> }) => {
   const { t: translate } = useTranslation("common");
-  const { data } = usePluginServiceGetPlugins();
-
-  let menuPlugins =
-    data?.plugins.flatMap((plugin) => plugin.external_views).filter((view) => 
view.destination === "nav") ??
-    [];
-
-  // Filter out plugins with categories that match existing buttons
-  menuPlugins = menuPlugins.filter((view) => {
-    const category = view.category?.toLowerCase();
-
-    return category === undefined || !existingCategories.includes(category);
-  });
-
-  const hasLegacyViews =
-    (
-      data?.plugins
-        .flatMap((plugin) => plugin.appbuilder_views)
-        // Only include legacy views that have a visible link in the menu. No 
menu items views
-        // are accessible via direct URLs.
-        .filter((view) => view.name !== undefined && view.name !== null) ?? []
-    ).length >= 1;
-
-  if (hasLegacyViews) {
-    menuPlugins = [
-      ...menuPlugins,
-      {
-        destination: "nav",
-        href: "/pluginsv2",
-        name: translate("nav.legacyFabViews"),
-        url_route: "legacy-fab-views",
-      },
-    ];
-  }
 
-  if (data === undefined || menuPlugins.length === 0) {
+  if (navItems.length === 0) {
     return undefined;
   }
 
-  const categories: Record<string, Array<ExternalViewResponse>> = {};
-  const buttons: Array<ExternalViewResponse> = [];
+  const categories: Record<string, Array<NavItemResponse>> = {};
+  const buttons: Array<NavItemResponse> = [];
 
-  menuPlugins.forEach((externalView) => {
-    if (externalView.category !== null && externalView.category !== undefined) 
{
-      categories[externalView.category] = 
[...(categories[externalView.category] ?? []), externalView];
+  navItems.forEach((navItem) => {
+    if (navItem.category !== null && navItem.category !== undefined) {
+      categories[navItem.category] = [...(categories[navItem.category] ?? []), 
navItem];
     } else {
-      buttons.push(externalView);
+      buttons.push(navItem);
     }
   });
 
-  if (!buttons.length && !Object.keys(categories).length && menuPlugins.length 
=== 0) {
+  if (!buttons.length && !Object.keys(categories).length && navItems.length 
=== 0) {
     return undefined;
   }
 
   // Show plugins in menu if there are more than 2
-  return menuPlugins.length > 2 ? (
+  return navItems.length > 2 ? (
     <Menu.Root positioning={{ placement: "right" }}>
       <Menu.Trigger>
         <NavButton as={Box} icon={<LuPlug />} title={translate("nav.plugins")} 
/>
       </Menu.Trigger>
       <Menu.Content>
-        {buttons.map((externalView) => (
-          <PluginMenuItem key={externalView.name} {...externalView} />
+        {buttons.map((navItem) => (
+          <PluginMenuItem key={navItem.name} {...navItem} />
         ))}
         {Object.entries(categories).map(([key, menuButtons]) => (
           <Menu.Root key={key} positioning={{ placement: "right" }}>
@@ -103,8 +66,8 @@ export const PluginMenus = () => {
               <FiChevronRight />
             </Menu.TriggerItem>
             <Menu.Content>
-              {menuButtons.map((externalView) => (
-                <PluginMenuItem {...externalView} key={externalView.name} />
+              {menuButtons.map((navItem) => (
+                <PluginMenuItem {...navItem} key={navItem.name} />
               ))}
             </Menu.Content>
           </Menu.Root>
@@ -112,6 +75,6 @@ export const PluginMenus = () => {
       </Menu.Content>
     </Menu.Root>
   ) : (
-    menuPlugins.map((plugin) => <PluginMenuItem {...plugin} key={plugin.name} 
topLevel={true} />)
+    navItems.map((navItem) => <PluginMenuItem {...navItem} key={navItem.name} 
topLevel={true} />)
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index dbb2afe4c22..d5ae323b562 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -22,9 +22,9 @@ import { FiGrid, FiLogOut, FiMoon, FiSun, FiUser, FiGlobe } 
from "react-icons/fi
 import { MdOutlineAccountTree } from "react-icons/md";
 import { useLocalStorage } from "usehooks-ts";
 
-import type { ExternalViewResponse } from "openapi/requests/types.gen";
 import { Menu } from "src/components/ui";
 import { useColorMode } from "src/context/colorMode/useColorMode";
+import type { NavItemResponse } from "src/utils/types";
 
 import LanguageModal from "./LanguageModal";
 import LogoutModal from "./LogoutModal";
@@ -33,11 +33,7 @@ import { PluginMenuItem } from "./PluginMenuItem";
 import { TimezoneMenuItem } from "./TimezoneMenuItem";
 import TimezoneModal from "./TimezoneModal";
 
-export const UserSettingsButton = ({
-  externalViews,
-}: {
-  readonly externalViews: Array<ExternalViewResponse>;
-}) => {
+export const UserSettingsButton = ({ externalViews }: { readonly 
externalViews: Array<NavItemResponse> }) => {
   const { t: translate } = useTranslation();
   const { colorMode, toggleColorMode } = useColorMode();
   const { onClose: onCloseTimezone, onOpen: onOpenTimezone, open: 
isOpenTimezone } = useDisclosure();
diff --git a/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx 
b/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
new file mode 100644
index 00000000000..a1c80cdf2f3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
@@ -0,0 +1,91 @@
+/*!
+ * 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 { Box } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import { usePluginServiceGetPlugins } from "openapi/queries";
+import { ProgressBar } from "src/components/ui";
+
+import { ErrorPage } from "./Error";
+import { Iframe } from "./Iframe";
+import { ReactPlugin } from "./ReactPlugin";
+
+export const ExternalView = () => {
+  const { t: translate } = useTranslation();
+  const { page } = useParams();
+  const { data: pluginData, isLoading } = usePluginServiceGetPlugins();
+
+  const externalView =
+    page === "legacy-fab-views"
+      ? {
+          destination: "nav" as const,
+          href: "/pluginsv2/",
+          name: translate("nav.legacyFabViews"),
+          url_route: "legacy-fab-views",
+        }
+      : pluginData?.plugins
+          .flatMap((plugin) => plugin.external_views)
+          .find((view) => (view.url_route ?? view.name.toLowerCase().replace(" 
", "-")) === page);
+
+  const reactApp = pluginData?.plugins
+    .flatMap((plugin) => plugin.react_apps)
+    .find((view) => (view.url_route ?? view.name.toLowerCase().replace(" ", 
"-")) === page);
+
+  if (isLoading) {
+    return (
+      <Box flexGrow={1}>
+        <ProgressBar />
+      </Box>
+    );
+  }
+
+  if (externalView) {
+    return (
+      <Box
+        flexGrow={1}
+        height="100%"
+        m={-2} // Compensate for parent padding
+        minHeight={0}
+      >
+        {/* 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. */}
+        <Iframe externalView={externalView} sandbox="allow-scripts 
allow-same-origin allow-forms" />
+      </Box>
+    );
+  }
+
+  if (reactApp) {
+    return (
+      <Box
+        flexGrow={1}
+        height="100%"
+        m={-2} // Compensate for parent padding
+        minHeight={0}
+      >
+        <ReactPlugin reactApp={reactApp} />
+      </Box>
+    );
+  }
+
+  return <ErrorPage />;
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx 
b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
index 91b804ed550..257e06b564b 100644
--- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
@@ -16,43 +16,23 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box } from "@chakra-ui/react";
-import { useTranslation } from "react-i18next";
 import { useParams } from "react-router-dom";
 
-import { usePluginServiceGetPlugins } from "openapi/queries";
-import { ProgressBar } from "src/components/ui";
+import type { ExternalViewResponse } from "openapi/requests/types.gen";
 
-import { ErrorPage } from "./Error";
-
-export const Iframe = ({ sandbox = "allow-same-origin allow-forms" }: { 
readonly sandbox: string }) => {
-  const { t: translate } = useTranslation();
-  const { dagId, mapIndex, page, runId, taskId } = useParams();
-  const { data: pluginData, isLoading } = usePluginServiceGetPlugins();
-
-  const iframeView =
-    page === "legacy-fab-views"
-      ? { href: "/pluginsv2/", name: translate("nav.legacyFabViews") }
-      : pluginData?.plugins
-          .flatMap((plugin) => plugin.external_views)
-          .find((view) => (view.url_route ?? view.name.toLowerCase().replace(" 
", "-")) === page);
-
-  if (!iframeView) {
-    if (isLoading) {
-      return (
-        <Box flexGrow={1}>
-          <ProgressBar />
-        </Box>
-      );
-    }
-
-    return <ErrorPage />;
-  }
+export const Iframe = ({
+  externalView,
+  sandbox = "allow-forms",
+}: {
+  readonly externalView: ExternalViewResponse;
+  readonly sandbox?: string;
+}) => {
+  const { dagId, mapIndex, runId, taskId } = useParams();
 
   // Build the href URL with context parameters if the view has a destination
-  let src = iframeView.href;
+  let src = externalView.href;
 
-  if (iframeView.destination !== undefined && iframeView.destination !== 
"nav") {
+  if (externalView.destination !== undefined && externalView.destination !== 
"nav") {
     // Check if the href contains placeholders that need to be replaced
     if (dagId !== undefined) {
       src = src.replaceAll("{DAG_ID}", dagId);
@@ -69,23 +49,16 @@ export const Iframe = ({ sandbox = "allow-same-origin 
allow-forms" }: { readonly
   }
 
   return (
-    <Box
-      flexGrow={1}
-      height="100%"
-      m={-2} // Compensate for parent padding
-      minHeight={0}
-    >
-      <iframe
-        sandbox={sandbox}
-        src={src}
-        style={{
-          border: "none",
-          display: "block",
-          height: "100%",
-          width: "100%",
-        }}
-        title={iframeView.name}
-      />
-    </Box>
+    <iframe
+      sandbox={sandbox}
+      src={new URL(src).toString()}
+      style={{
+        border: "none",
+        display: "block",
+        height: "100%",
+        width: "100%",
+      }}
+      title={externalView.name}
+    />
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
new file mode 100644
index 00000000000..0f80af777c8
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -0,0 +1,46 @@
+/*!
+ * 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 { Spinner } from "@chakra-ui/react";
+import { lazy, Suspense } from "react";
+import { useParams } from "react-router-dom";
+
+import type { ReactAppResponse } from "openapi/requests/types.gen";
+
+import { ErrorPage } from "./Error";
+
+export const ReactPlugin = ({ reactApp }: { readonly reactApp: 
ReactAppResponse }) => {
+  const { dagId, mapIndex, runId, taskId } = useParams();
+
+  const Plugin = lazy(() =>
+    // We are assuming the plugin manager is trusted and the bundle_url is safe
+    import(/* @vite-ignore */ reactApp.bundle_url).catch((error: unknown) => {
+      console.error("Component Failed Loading:", error);
+
+      return {
+        default: <ErrorPage />,
+      };
+    }),
+  );
+
+  return (
+    <Suspense fallback={<Spinner />}>
+      <Plugin 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 4c38a112d32..fca1828e8ba 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -38,8 +38,8 @@ import { DagsList } from "src/pages/DagsList";
 import { Dashboard } from "src/pages/Dashboard";
 import { ErrorPage } from "src/pages/Error";
 import { Events } from "src/pages/Events";
+import { ExternalView } from "src/pages/ExternalView";
 import { GroupTaskInstance } from "src/pages/GroupTaskInstance";
-import { Iframe } from "src/pages/Iframe";
 import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
 import { Plugins } from "src/pages/Plugins";
 import { Pools } from "src/pages/Pools";
@@ -60,13 +60,8 @@ 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" />,
+const pluginRoute = {
+  element: <ExternalView />,
   path: "plugin/:page",
 };
 
@@ -79,7 +74,7 @@ const taskInstanceRoutes = [
   { element: <RenderedTemplates />, path: "rendered_templates" },
   { element: <TaskInstances />, path: "task_instances" },
   { element: <TaskInstanceAssetEvents />, path: "asset_events" },
-  iframeRoute,
+  pluginRoute,
 ];
 
 export const routerConfig = [
@@ -153,7 +148,7 @@ export const routerConfig = [
         element: <Connections />,
         path: "connections",
       },
-      iframeRoute,
+      pluginRoute,
       {
         children: [
           { element: <Overview />, index: true },
@@ -163,7 +158,7 @@ export const routerConfig = [
           { element: <Events />, path: "events" },
           { element: <Code />, path: "code" },
           { element: <DagDetails />, path: "details" },
-          iframeRoute,
+          pluginRoute,
         ],
         element: <Dag />,
         path: "dags/:dagId",
@@ -175,7 +170,7 @@ export const routerConfig = [
           { element: <Code />, path: "code" },
           { element: <DagRunDetails />, path: "details" },
           { element: <DagRunAssetEvents />, path: "asset_events" },
-          iframeRoute,
+          pluginRoute,
         ],
         element: <Run />,
         path: "dags/:dagId/runs/:runId",
@@ -199,7 +194,7 @@ export const routerConfig = [
         children: [
           { element: <TaskOverview />, index: true },
           { element: <TaskInstances />, path: "task_instances" },
-          iframeRoute,
+          pluginRoute,
         ],
         element: <Task />,
         path: "dags/:dagId/tasks/group/:groupId",
@@ -214,7 +209,7 @@ export const routerConfig = [
           { element: <TaskOverview />, index: true },
           { element: <TaskInstances />, path: "task_instances" },
           { element: <Events />, path: "events" },
-          iframeRoute,
+          pluginRoute,
         ],
         element: <Task />,
         path: "dags/:dagId/tasks/:taskId",
diff --git a/airflow-core/src/airflow/ui/src/utils/types.ts 
b/airflow-core/src/airflow/ui/src/utils/types.ts
new file mode 100644
index 00000000000..f01f8927517
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/utils/types.ts
@@ -0,0 +1,22 @@
+/*!
+ * 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 { ExternalViewResponse, ReactAppResponse } from 
"openapi/requests/types.gen";
+
+// Union type for navigation items that can be either external views or react 
apps
+export type NavItemResponse = ExternalViewResponse | ReactAppResponse;

Reply via email to