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 dd68ede22c938..6862fb2e26562 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 1b32b7643724d..3ddcf0b1c8d82 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 29287fe53d2dd..fc4f1711c40e2 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 d16375c10a8b1..38bc1662831dc 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 0000000000000..50f50dbdd9ab6
--- /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 }) =>
{reactApp.name}
,
+}));
+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(, { 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 3efeec528cc3d..3144be5d38040 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 (
-
-
+
+
{
startDate={startDate}
/>
-
+
{
startDate={startDate}
/>
-
+
{
/>
) : undefined}
- {dagId === undefined ? undefined : }
- }>
-
-
-
+ {dagId === undefined ? undefined : (
+
+
+
+ )}
+
+ }>
+
+
+
+ {dagOverviewReactPlugins.map((plugin) => (
+
+ ))}
+
);
};
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 0000000000000..a545d562a680a
--- /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 }) => {reactApp.name}
,
+}));
+vi.mock("src/utils", () => ({ isStatePending: () => false, useAutoRefresh: () => false }));
+
+describe("Task overview plugins", () => {
+ it("renders only React plugins registered for the task overview", () => {
+ render(, { 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 f48bd9e26cf90..4bf64d3a934d6 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 (
-
-
-
+
+
+
+
+
{
startDate={startDate}
/>
-
+
{
startDate={startDate}
/>
-
+
{
)}
-
+ {taskOverviewReactPlugins.map((plugin) => (
+
+ ))}
+
);
};
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 0000000000000..63ef7e11fe64a
--- /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
+
+
+@pytest.mark.parametrize("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 c277d7e470c1b..3d3fea1fc4320 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):