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 b9bf4880d9b Add task group detail across dag runs (#50412) b9bf4880d9b is described below commit b9bf4880d9bc0f015d0114f595829a1767048160 Author: Guan Ming(Wesley) Chiu <105915352+guan404m...@users.noreply.github.com> AuthorDate: Fri May 16 01:36:45 2025 +0800 Add task group detail across dag runs (#50412) --- .../airflow/ui/src/components/Graph/TaskLink.tsx | 25 ++++------ .../src/airflow/ui/src/components/TaskName.tsx | 1 - .../ui/src/layouts/Details/DagBreadcrumb.tsx | 19 +++++++- .../ui/src/layouts/Details/Grid/TaskNames.tsx | 31 +++++++----- .../airflow/ui/src/pages/Task/GroupTaskHeader.tsx | 26 ++++++++++ .../ui/src/pages/Task/Overview/Overview.tsx | 8 ++-- .../src/airflow/ui/src/pages/Task/Task.tsx | 37 +++++++++++++-- airflow-core/src/airflow/ui/src/router.tsx | 8 ++++ airflow-core/src/airflow/ui/src/utils/groupTask.ts | 55 ++++++++++++++++++++++ 9 files changed, 175 insertions(+), 35 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx index 4a86c5eb9a5..203bfb652ee 100644 --- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx +++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx @@ -26,25 +26,20 @@ type Props = { } & TaskNameProps; export const TaskLink = forwardRef<HTMLAnchorElement, Props>(({ id, isGroup, isMapped, ...rest }, ref) => { - const { dagId = "", runId, taskId } = useParams(); + const { dagId = "", groupId, runId, taskId } = useParams(); const [searchParams] = useSearchParams(); - if (isGroup && runId === undefined) { - return undefined; - } - - const pathname = isGroup - ? `/dags/${dagId}/runs/${runId}/tasks/group/${id}` - : `/dags/${dagId}/${runId === undefined ? "" : `runs/${runId}/`}${taskId === id ? "" : `tasks/${id}`}${isMapped && taskId !== id && runId !== undefined ? "/mapped" : ""}`; + const basePath = `/dags/${dagId}${runId === undefined ? "" : `/runs/${runId}`}`; + const taskPath = isGroup + ? groupId === id + ? "" + : `/tasks/group/${id}` + : taskId === id + ? "" + : `/tasks/${id}${isMapped && taskId !== id && runId !== undefined ? "/mapped" : ""}`; return ( - <RouterLink - ref={ref} - to={{ - pathname, - search: searchParams.toString(), - }} - > + <RouterLink ref={ref} to={{ pathname: basePath + taskPath, search: searchParams.toString() }}> <TaskName isGroup={isGroup} isMapped={isMapped} {...rest} /> </RouterLink> ); diff --git a/airflow-core/src/airflow/ui/src/components/TaskName.tsx b/airflow-core/src/airflow/ui/src/components/TaskName.tsx index b2c1ebcea2b..9349cb27a51 100644 --- a/airflow-core/src/airflow/ui/src/components/TaskName.tsx +++ b/airflow-core/src/airflow/ui/src/components/TaskName.tsx @@ -48,7 +48,6 @@ export const TaskName = ({ setupTeardownType, ...rest }: TaskNameProps) => { - // We don't have a task group details page to link to if (isGroup) { return ( <Text diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx index 7afa19f9a6f..6e175b9b28e 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx @@ -32,7 +32,7 @@ import { TogglePause } from "src/components/TogglePause"; import { isStatePending, useAutoRefresh } from "src/utils"; export const DagBreadcrumb = () => { - const { dagId = "", mapIndex = "-1", runId, taskId } = useParams(); + const { dagId = "", groupId, mapIndex = "-1", runId, taskId } = useParams(); const refetchInterval = useAutoRefresh({ dagId }); const { data: dag } = useDagServiceGetDagDetails({ @@ -86,6 +86,23 @@ export const DagBreadcrumb = () => { }); } + // Add group breadcrumb + if (groupId !== undefined) { + if (runId === undefined) { + links.push({ + label: "All Runs", + title: "Dag Run", + value: `/dags/${dagId}/runs`, + }); + } + + links.push({ + label: groupId, + title: "Group", + value: `/dags/${dagId}/groups/${groupId}`, + }); + } + // Add task breadcrumb if (runId !== undefined && taskId !== undefined) { if (task?.is_mapped) { diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx index 2061066a935..9792b1f4c54 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx @@ -65,17 +65,26 @@ export const TaskNames = ({ nodes }: Props) => { transition="background-color 0.2s" > {node.isGroup ? ( - <Flex> - <TaskName - display="inline" - fontSize="sm" - fontWeight="normal" - isGroup={true} - isMapped={Boolean(node.is_mapped)} - label={node.label} - paddingLeft={node.depth * 3 + 2} - setupTeardownType={node.setup_teardown_type} - /> + <Flex alignItems="center"> + <Link data-testid={node.id} display="inline"> + <RouterLink + replace + to={{ + pathname: `/dags/${dagId}/tasks/group/${node.id}`, + search: searchParams.toString(), + }} + > + <TaskName + fontSize="sm" + fontWeight="normal" + isGroup={true} + isMapped={Boolean(node.is_mapped)} + label={node.label} + paddingLeft={node.depth * 3 + 2} + setupTeardownType={node.setup_teardown_type} + /> + </RouterLink> + </Link> <chakra.button aria-label="Toggle group" display="inline" diff --git a/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx new file mode 100644 index 00000000000..fc0a2f29233 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Task/GroupTaskHeader.tsx @@ -0,0 +1,26 @@ +/*! + * 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 { AiOutlineGroup } from "react-icons/ai"; + +import type { NodeResponse } from "openapi/requests/types.gen"; +import { HeaderCard } from "src/components/HeaderCard"; + +export const GroupTaskHeader = ({ groupTask }: { readonly groupTask: NodeResponse }) => ( + <HeaderCard icon={<AiOutlineGroup />} stats={[]} title={groupTask.label} /> +); 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 24ef0fb2067..e36a613eb61 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 @@ -30,7 +30,7 @@ import { isStatePending, useAutoRefresh } from "src/utils"; const defaultHour = "24"; export const Overview = () => { - const { dagId = "", taskId } = useParams(); + const { dagId = "", groupId, taskId } = useParams(); const now = dayjs(); const [startDate, setStartDate] = useState(now.subtract(Number(defaultHour), "hour").toISOString()); @@ -46,7 +46,8 @@ export const Overview = () => { runAfterGte: startDate, runAfterLte: endDate, state: ["failed"], - taskId, + taskDisplayNamePattern: groupId ?? undefined, + taskId: Boolean(groupId) ? undefined : taskId, }); const { data: taskInstances, isLoading: isLoadingTaskInstances } = useTaskInstanceServiceGetTaskInstances( @@ -55,7 +56,8 @@ export const Overview = () => { dagRunId: "~", limit: 14, orderBy: "-run_after", - taskId, + taskDisplayNamePattern: groupId ?? undefined, + taskId: Boolean(groupId) ? undefined : taskId, }, undefined, { diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx index 44da8f8368b..dd20f01a03f 100644 --- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx @@ -21,9 +21,11 @@ import { LuChartColumn } from "react-icons/lu"; import { MdOutlineEventNote, MdOutlineTask } from "react-icons/md"; import { useParams } from "react-router-dom"; -import { useDagServiceGetDagDetails, useTaskServiceGetTask } from "openapi/queries"; +import { useDagServiceGetDagDetails, useGridServiceGridData, useTaskServiceGetTask } from "openapi/queries"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; +import { getGroupTask } from "src/utils/groupTask"; +import { GroupTaskHeader } from "./GroupTaskHeader"; import { Header } from "./Header"; const tabs = [ @@ -33,9 +35,30 @@ const tabs = [ ]; export const Task = () => { - const { dagId = "", taskId = "" } = useParams(); + const { dagId = "", groupId, taskId } = useParams(); - const { data: task, error, isLoading } = useTaskServiceGetTask({ dagId, taskId }); + const displayTabs = groupId === undefined ? tabs : tabs.filter((tab) => tab.label !== "Events"); + + const { + data: task, + error, + isLoading, + } = useTaskServiceGetTask({ dagId, taskId: groupId ?? taskId }, undefined, { + enabled: groupId === undefined, + }); + + const { data: gridData } = useGridServiceGridData( + { + dagId, + includeDownstream: true, + includeUpstream: true, + }, + undefined, + { enabled: groupId !== undefined }, + ); + + const groupTask = + groupId === undefined ? undefined : getGroupTask(gridData?.structure.nodes ?? [], groupId); const { data: dag, @@ -47,8 +70,14 @@ export const Task = () => { return ( <ReactFlowProvider> - <DetailsLayout dag={dag} error={error ?? dagError} isLoading={isLoading || isDagLoading} tabs={tabs}> + <DetailsLayout + dag={dag} + error={error ?? dagError} + isLoading={isLoading || isDagLoading} + tabs={displayTabs} + > {task === undefined ? undefined : <Header task={task} />} + {groupTask ? <GroupTaskHeader groupTask={groupTask} /> : undefined} </DetailsLayout> </ReactFlowProvider> ); diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx index 39d7beed9a2..3b7edfdb39e 100644 --- a/airflow-core/src/airflow/ui/src/router.tsx +++ b/airflow-core/src/airflow/ui/src/router.tsx @@ -176,6 +176,14 @@ export const routerConfig = [ element: <GroupTaskInstance />, path: "dags/:dagId/runs/:runId/tasks/group/:groupId", }, + { + children: [ + { element: <TaskOverview />, index: true }, + { element: <TaskInstances />, path: "task_instances" }, + ], + element: <Task />, + path: "dags/:dagId/tasks/group/:groupId", + }, { children: taskInstanceRoutes, element: <TaskInstance />, diff --git a/airflow-core/src/airflow/ui/src/utils/groupTask.ts b/airflow-core/src/airflow/ui/src/utils/groupTask.ts new file mode 100644 index 00000000000..8b736dc7c8a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/groupTask.ts @@ -0,0 +1,55 @@ +/*! + * 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 { NodeResponse } from "openapi/requests/types.gen"; + +/** + * Finds a task node by its ID in a tree of nodes + * @param nodes - Array of root nodes to search through + * @param targetId - ID of the node to find + * @returns The found node or undefined if not found + */ +export const getGroupTask = (nodes: Array<NodeResponse>, targetId: string): NodeResponse | undefined => { + if (!nodes.length || !targetId) { + return undefined; + } + + const queue: Array<NodeResponse> = [...nodes]; + const [root] = targetId.split("."); + + while (queue.length > 0) { + const node = queue.shift(); + + if (node) { + if (node.id === targetId) { + return node; + } + + if (node.children && node.children.length > 0) { + const nextNode = + node.id === root && targetId.includes(".") + ? node.children.find((child) => child.id === targetId) + : undefined; + + queue.unshift(...(nextNode ? [nextNode] : node.children)); + } + } + } + + return undefined; +};