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):