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;