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 8c205ef4083 Add React plugin support to Dag and task overviews (#69992)
8c205ef4083 is described below

commit 8c205ef40834d70e489a8ae38c5a441b1535129a
Author: Shae Alhusayni <[email protected]>
AuthorDate: Wed Jul 22 08:42:48 2026 -0700

    Add React plugin support to Dag and task overviews (#69992)
---
 .../api_fastapi/core_api/datamodels/plugins.py     |  2 +-
 .../core_api/openapi/v2-rest-api-generated.yaml    |  2 +
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts |  2 +-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  4 +-
 .../ui/src/pages/Dag/Overview/Overview.test.tsx    | 68 ++++++++++++++++++++++
 .../airflow/ui/src/pages/Dag/Overview/Overview.tsx | 37 ++++++++----
 .../ui/src/pages/Task/Overview/Overview.test.tsx   | 63 ++++++++++++++++++++
 .../ui/src/pages/Task/Overview/Overview.tsx        | 28 ++++++---
 .../core_api/datamodels/test_plugins.py            | 32 ++++++++++
 .../src/airflowctl/api/datamodels/generated.py     |  2 +
 10 files changed, 218 insertions(+), 22 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
index dd68ede22c9..6862fb2e265 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/plugins.py
@@ -100,7 +100,7 @@ class ReactAppResponse(BaseUIResponse):
     model_config = ConfigDict(extra="allow")
 
     bundle_url: str
-    destination: Literal[BaseDestinationLiteral, "dashboard"] = "nav"
+    destination: Literal[BaseDestinationLiteral, "dashboard", "dag_overview", 
"task_overview"] = "nav"
 
 
 class PluginResponse(BaseModel):
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 1b32b764372..3ddcf0b1c8d 100644
--- 
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++ 
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -15909,6 +15909,8 @@ components:
           - asset
           - base
           - dashboard
+          - dag_overview
+          - task_overview
           title: Destination
           default: nav
       additionalProperties: true
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 29287fe53d2..fc4f1711c40 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -6383,7 +6383,7 @@ export const $ReactAppResponse = {
         },
         destination: {
             type: 'string',
-            enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'asset', 
'base', 'dashboard'],
+            enum: ['nav', 'dag', 'dag_run', 'task', 'task_instance', 'asset', 
'base', 'dashboard', 'dag_overview', 'task_overview'],
             title: 'Destination',
             default: 'nav'
         }
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index d16375c10a8..38bc1662831 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -1679,11 +1679,11 @@ export type ReactAppResponse = {
     category?: string | null;
     nav_top_level?: boolean | null;
     bundle_url: string;
-    destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 
'asset' | 'base' | 'dashboard';
+    destination?: 'nav' | 'dag' | 'dag_run' | 'task' | 'task_instance' | 
'asset' | 'base' | 'dashboard' | 'dag_overview' | 'task_overview';
     [key: string]: unknown | string;
 };
 
-export type destination2 = 'nav' | 'dag' | 'dag_run' | 'task' | 
'task_instance' | 'asset' | 'base' | 'dashboard';
+export type destination2 = 'nav' | 'dag' | 'dag_run' | 'task' | 
'task_instance' | 'asset' | 'base' | 'dashboard' | 'dag_overview' | 
'task_overview';
 
 /**
  * Internal enum for setting reprocess behavior in a backfill.
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx
new file mode 100644
index 00000000000..50f50dbdd9a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx
@@ -0,0 +1,68 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ReactAppResponse } from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Overview } from "./Overview";
+
+vi.mock("openapi/queries", () => ({
+  useAssetServiceGetAssetEvents: () => ({ data: undefined, isLoading: false }),
+  useDagRunServiceGetDagRuns: () => ({ data: { dag_runs: [], total_entries: 0 
}, isLoading: false }),
+  usePluginServiceGetPlugins: () => ({
+    data: {
+      plugins: [
+        {
+          react_apps: [
+            { bundle_url: "/dag.js", destination: "dag_overview", name: "Dag 
overview plugin" },
+            { bundle_url: "/task.js", destination: "task_overview", name: 
"Task overview plugin" },
+          ],
+        },
+      ],
+    },
+  }),
+  useTaskInstanceServiceGetTaskInstances: () => ({
+    data: { task_instances: [], total_entries: 0 },
+    isLoading: false,
+  }),
+}));
+
+vi.mock("src/components/Assets/AssetEvents", () => ({ AssetEvents: () => null 
}));
+vi.mock("src/components/DurationChart", () => ({ DurationChart: () => null }));
+vi.mock("src/components/TimeRangeSelector", () => ({ default: () => null }));
+vi.mock("src/components/TrendCountButton", () => ({ TrendCountButton: () => 
null }));
+vi.mock("src/pages/ReactPlugin", () => ({
+  ReactPlugin: ({ reactApp }: { readonly reactApp: ReactAppResponse }) => 
<div>{reactApp.name}</div>,
+}));
+vi.mock("src/queries/useGridRuns.ts", () => ({ useGridRuns: () => ({ data: [], 
isLoading: false }) }));
+vi.mock("src/utils", () => ({ isStatePending: () => false, useAutoRefresh: () 
=> false }));
+vi.mock("./DagDeadlines", () => ({ DagDeadlines: () => null }));
+vi.mock("./FailedLogs", () => ({ default: () => null }));
+
+describe("Dag overview plugins", () => {
+  it("renders only React plugins registered for the Dag overview", () => {
+    render(<Overview />, { wrapper: Wrapper });
+
+    expect(screen.getByText("Dag overview plugin")).toBeInTheDocument();
+    expect(screen.queryByText("Task overview plugin")).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
index 3efeec528cc..3144be5d380 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, HStack, Skeleton } from "@chakra-ui/react";
+import { Box, HStack, Skeleton, VStack } from "@chakra-ui/react";
 import dayjs from "dayjs";
 import { lazy, useState, Suspense } from "react";
 import { useTranslation } from "react-i18next";
@@ -26,14 +26,17 @@ import { useLocalStorage } from "usehooks-ts";
 import {
   useAssetServiceGetAssetEvents,
   useDagRunServiceGetDagRuns,
+  usePluginServiceGetPlugins,
   useTaskInstanceServiceGetTaskInstances,
 } from "openapi/queries";
+import type { ReactAppResponse } from "openapi/requests/types.gen";
 import { AssetEvents } from "src/components/Assets/AssetEvents";
 import { DurationChart } from "src/components/DurationChart";
 import TimeRangeSelector from "src/components/TimeRangeSelector";
 import { TrendCountButton } from "src/components/TrendCountButton";
 import { dagRunsLimitKey } from "src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { ReactPlugin } from "src/pages/ReactPlugin";
 import { useGridRuns } from "src/queries/useGridRuns.ts";
 import { isStatePending, useAutoRefresh } from "src/utils";
 
@@ -83,10 +86,15 @@ export const Overview = () => {
     timestampGte: startDate,
     timestampLte: endDate,
   });
+  const { data: pluginData } = usePluginServiceGetPlugins();
+  const dagOverviewReactPlugins =
+    pluginData?.plugins
+      .flatMap((plugin) => plugin.react_apps)
+      .filter((plugin: ReactAppResponse) => plugin.destination === 
"dag_overview") ?? [];
 
   return (
-    <Box m={4} spaceY={4}>
-      <Box my={2}>
+    <VStack alignItems="stretch" gap={4} m={4}>
+      <Box my={2} order={1}>
         <TimeRangeSelector
           defaultValue={defaultHour}
           endDate={endDate}
@@ -95,7 +103,7 @@ export const Overview = () => {
           startDate={startDate}
         />
       </Box>
-      <HStack flexWrap="wrap">
+      <HStack flexWrap="wrap" order={2}>
         <TrendCountButton
           colorPalette={failedTaskCount === 0 ? "green" : "failed"}
           count={failedTaskCount}
@@ -127,7 +135,7 @@ export const Overview = () => {
           startDate={startDate}
         />
       </HStack>
-      <HStack alignItems="flex-start" flexWrap="wrap">
+      <HStack alignItems="flex-start" flexWrap="wrap" order={3}>
         <Box
           borderRadius={4}
           borderStyle="solid"
@@ -157,10 +165,19 @@ export const Overview = () => {
           />
         ) : undefined}
       </HStack>
-      {dagId === undefined ? undefined : <DagDeadlines dagId={dagId} />}
-      <Suspense fallback={<Skeleton height="100px" width="full" />}>
-        <FailedLogs failedTasks={failedTasks} />
-      </Suspense>
-    </Box>
+      {dagId === undefined ? undefined : (
+        <Box css={{ "&:empty": { display: "none" } }} order={4}>
+          <DagDeadlines dagId={dagId} />
+        </Box>
+      )}
+      <Box css={{ "&:empty": { display: "none" } }} order={5}>
+        <Suspense fallback={<Skeleton height="100px" width="full" />}>
+          <FailedLogs failedTasks={failedTasks} />
+        </Suspense>
+      </Box>
+      {dagOverviewReactPlugins.map((plugin) => (
+        <ReactPlugin key={plugin.name} reactApp={plugin} />
+      ))}
+    </VStack>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.test.tsx
new file mode 100644
index 00000000000..a545d562a68
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.test.tsx
@@ -0,0 +1,63 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import type { ReactAppResponse } from "openapi/requests/types.gen";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Overview } from "./Overview";
+
+vi.mock("openapi/queries", () => ({
+  usePluginServiceGetPlugins: () => ({
+    data: {
+      plugins: [
+        {
+          react_apps: [
+            { bundle_url: "/dag.js", destination: "dag_overview", name: "Dag 
overview plugin" },
+            { bundle_url: "/task.js", destination: "task_overview", name: 
"Task overview plugin" },
+          ],
+        },
+      ],
+    },
+  }),
+  useTaskInstanceServiceGetTaskInstances: () => ({
+    data: { task_instances: [], total_entries: 0 },
+    isLoading: false,
+  }),
+}));
+
+vi.mock("src/components/DurationChart", () => ({ DurationChart: () => null }));
+vi.mock("src/components/NeedsReviewButton", () => ({ NeedsReviewButton: () => 
null }));
+vi.mock("src/components/TimeRangeSelector", () => ({ default: () => null }));
+vi.mock("src/components/TrendCountButton", () => ({ TrendCountButton: () => 
null }));
+vi.mock("src/pages/ReactPlugin", () => ({
+  ReactPlugin: ({ reactApp }: { readonly reactApp: ReactAppResponse }) => 
<div>{reactApp.name}</div>,
+}));
+vi.mock("src/utils", () => ({ isStatePending: () => false, useAutoRefresh: () 
=> false }));
+
+describe("Task overview plugins", () => {
+  it("renders only React plugins registered for the task overview", () => {
+    render(<Overview />, { wrapper: Wrapper });
+
+    expect(screen.getByText("Task overview plugin")).toBeInTheDocument();
+    expect(screen.queryByText("Dag overview plugin")).not.toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx 
b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
index f48bd9e26cf..4bf64d3a934 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.tsx
@@ -16,18 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, HStack, Skeleton } from "@chakra-ui/react";
+import { Box, HStack, Skeleton, VStack } from "@chakra-ui/react";
 import dayjs from "dayjs";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
 import { useParams } from "react-router-dom";
 
-import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries";
+import { usePluginServiceGetPlugins, useTaskInstanceServiceGetTaskInstances } 
from "openapi/queries";
+import type { ReactAppResponse } from "openapi/requests/types.gen";
 import { DurationChart } from "src/components/DurationChart";
 import { NeedsReviewButton } from "src/components/NeedsReviewButton";
 import TimeRangeSelector from "src/components/TimeRangeSelector";
 import { TrendCountButton } from "src/components/TrendCountButton";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { ReactPlugin } from "src/pages/ReactPlugin";
 import { isStatePending, useAutoRefresh } from "src/utils";
 
 const defaultHour = "24";
@@ -71,11 +73,18 @@ export const Overview = () => {
         query.state.data?.task_instances.some((ti) => 
isStatePending(ti.state)) ? refetchInterval : false,
     },
   );
+  const { data: pluginData } = usePluginServiceGetPlugins();
+  const taskOverviewReactPlugins =
+    pluginData?.plugins
+      .flatMap((plugin) => plugin.react_apps)
+      .filter((plugin: ReactAppResponse) => plugin.destination === 
"task_overview") ?? [];
 
   return (
-    <Box m={4} spaceY={4}>
-      <NeedsReviewButton taskId={taskId} />
-      <Box my={2}>
+    <VStack alignItems="stretch" gap={4} m={4}>
+      <Box css={{ "&:empty": { display: "none" } }} order={1}>
+        <NeedsReviewButton taskId={taskId} />
+      </Box>
+      <Box my={2} order={2}>
         <TimeRangeSelector
           defaultValue={defaultHour}
           endDate={endDate}
@@ -84,7 +93,7 @@ export const Overview = () => {
           startDate={startDate}
         />
       </Box>
-      <HStack flexWrap="wrap">
+      <HStack flexWrap="wrap" order={3}>
         <TrendCountButton
           colorPalette={failedTaskCount === 0 ? "green" : "red"}
           count={failedTaskCount}
@@ -103,7 +112,7 @@ export const Overview = () => {
           startDate={startDate}
         />
       </HStack>
-      <HStack alignItems="flex-start" flexWrap="wrap" gap={5} my={5}>
+      <HStack alignItems="flex-start" flexWrap="wrap" gap={5} my={5} order={4}>
         <Box
           borderRadius={4}
           borderStyle="solid"
@@ -120,6 +129,9 @@ export const Overview = () => {
           )}
         </Box>
       </HStack>
-    </Box>
+      {taskOverviewReactPlugins.map((plugin) => (
+        <ReactPlugin key={plugin.name} reactApp={plugin} />
+      ))}
+    </VStack>
   );
 };
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py 
b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py
new file mode 100644
index 00000000000..63ef7e11fe6
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py
@@ -0,0 +1,32 @@
+# 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.
+from __future__ import annotations
+
+from typing import Literal
+
+import pytest
+
+from airflow.api_fastapi.core_api.datamodels.plugins import ReactAppResponse
+
+
[email protected]("destination", ["dag_overview", "task_overview"])
+def test_react_app_accepts_overview_destination(
+    destination: Literal["dag_overview", "task_overview"],
+) -> None:
+    react_app = ReactAppResponse(name="overview", bundle_url="/overview.js", 
destination=destination)
+
+    assert react_app.destination == destination
diff --git a/airflow-ctl/src/airflowctl/api/datamodels/generated.py 
b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
index c277d7e470c..3d3fea1fc43 100644
--- a/airflow-ctl/src/airflowctl/api/datamodels/generated.py
+++ b/airflow-ctl/src/airflowctl/api/datamodels/generated.py
@@ -935,6 +935,8 @@ class Destination1(str, Enum):
     ASSET = "asset"
     BASE = "base"
     DASHBOARD = "dashboard"
+    DAG_OVERVIEW = "dag_overview"
+    TASK_OVERVIEW = "task_overview"
 
 
 class ReactAppResponse(BaseModel):

Reply via email to