From b7ebd6e5f60da0f8a4d77dba083b3c7fe51c9a27 Mon Sep 17 00:00:00 2001 From: shaealh Date: Thu, 16 Jul 2026 22:48:01 -0700 Subject: [PATCH] Add React plugin support to Dag and task overviews --- .../core_api/datamodels/plugins.py | 2 +- .../openapi/v2-rest-api-generated.yaml | 2 + .../ui/openapi-gen/requests/schemas.gen.ts | 2 +- .../ui/openapi-gen/requests/types.gen.ts | 4 +- .../src/pages/Dag/Overview/Overview.test.tsx | 68 +++++++++++++++++++ .../ui/src/pages/Dag/Overview/Overview.tsx | 37 +++++++--- .../src/pages/Task/Overview/Overview.test.tsx | 63 +++++++++++++++++ .../ui/src/pages/Task/Overview/Overview.tsx | 28 +++++--- .../core_api/datamodels/test_plugins.py | 32 +++++++++ .../airflowctl/api/datamodels/generated.py | 2 + 10 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/Dag/Overview/Overview.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/pages/Task/Overview/Overview.test.tsx create mode 100644 airflow-core/tests/unit/api_fastapi/core_api/datamodels/test_plugins.py 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):