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;
+};

Reply via email to