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 c7afcfc568b Add task group detail for dag run selected case (#50309)
c7afcfc568b is described below

commit c7afcfc568b5f1bfc5c5be5b780ddc0f8320f976
Author: Guan Ming(Wesley) Chiu <[email protected]>
AuthorDate: Thu May 8 23:49:04 2025 +0800

    Add task group detail for dag run selected case (#50309)
    
    * Add group detail for dag run selected
    
    * Apply suggestions from code review
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    
    * Fix TaskLink structure
    
    * Fix crosshair selection
    
    * Update GridTI bg
    
    ---------
    
    Co-authored-by: Brent Bovenzi <[email protected]>
---
 .../airflow/ui/src/components/Graph/TaskLink.tsx   | 14 ++--
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 50 +++++++-----
 .../ui/src/layouts/Details/Grid/TaskNames.tsx      |  4 +-
 .../pages/GroupTaskInstance/GroupTaskInstance.tsx  | 89 ++++++++++++++++++++++
 .../ui/src/pages/GroupTaskInstance/Header.tsx      | 65 ++++++++++++++++
 .../ui/src/pages/GroupTaskInstance/index.ts        | 20 +++++
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   |  8 +-
 airflow-core/src/airflow/ui/src/router.tsx         |  6 ++
 8 files changed, 223 insertions(+), 33 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 278c6af3092..4a86c5eb9a5 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
@@ -29,21 +29,23 @@ export const TaskLink = forwardRef<HTMLAnchorElement, 
Props>(({ id, isGroup, isM
   const { dagId = "", runId, taskId } = useParams();
   const [searchParams] = useSearchParams();
 
-  // We don't have a task group details page to link to
-  if (isGroup) {
-    return <TaskName isGroup={true} isMapped={isMapped} {...rest} />;
+  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" : ""}`;
+
   return (
     <RouterLink
       ref={ref}
       to={{
-        // Do not include runId if there is no selected run, clicking a second 
time will deselect a task id
-        pathname: `/dags/${dagId}/${runId === undefined ? "" : 
`runs/${runId}/`}${taskId === id ? "" : `tasks/${id}`}${isMapped && taskId !== 
id && runId !== undefined ? "/mapped" : ""}`,
+        pathname,
         search: searchParams.toString(),
       }}
     >
-      <TaskName isMapped={isMapped} {...rest} />
+      <TaskName isGroup={isGroup} isMapped={isMapped} {...rest} />
     </RouterLink>
   );
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index 08a17ea9e75..cd737af673a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -52,12 +52,12 @@ const onMouseLeave = (event: MouseEvent<HTMLDivElement>) => 
{
 };
 
 const Instance = ({ dagId, isGroup, isMapped, runId, search, state, taskId }: 
Props) => {
-  const { taskId: selectedTaskId } = useParams();
+  const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
 
   return (
     <Flex
       alignItems="center"
-      bg={selectedTaskId === taskId ? "blue.muted" : undefined}
+      bg={selectedTaskId === taskId || selectedGroupId === taskId ? 
"blue.muted" : undefined}
       height="20px"
       id={taskId.replaceAll(".", "-")}
       justifyContent="center"
@@ -70,26 +70,34 @@ const Instance = ({ dagId, isGroup, isMapped, runId, 
search, state, taskId }: Pr
       zIndex={1}
     >
       {isGroup ? (
-        <Badge
-          borderRadius={4}
-          colorPalette={state === null ? "none" : state}
-          height="14px"
-          minH={0}
-          opacity={state === "success" ? 0.6 : 1}
-          p={0}
-          variant="solid"
-          width="14px"
+        <Link
+          replace
+          to={{
+            pathname: `/dags/${dagId}/runs/${runId}/tasks/group/${taskId}`,
+            search,
+          }}
         >
-          {state === undefined ? undefined : (
-            <StateIcon
-              size={10}
-              state={state}
-              style={{
-                marginLeft: "2px",
-              }}
-            />
-          )}
-        </Badge>
+          <Badge
+            borderRadius={4}
+            colorPalette={state === null ? "none" : state}
+            height="14px"
+            minH={0}
+            opacity={state === "success" ? 0.6 : 1}
+            p={0}
+            variant="solid"
+            width="14px"
+          >
+            {state === undefined ? undefined : (
+              <StateIcon
+                size={10}
+                state={state}
+                style={{
+                  marginLeft: "2px",
+                }}
+              />
+            )}
+          </Badge>
+        </Link>
       ) : (
         <Link
           replace
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 60105579223..2061066a935 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
@@ -49,12 +49,12 @@ const onMouseLeave = (event: MouseEvent<HTMLDivElement>) => 
{
 
 export const TaskNames = ({ nodes }: Props) => {
   const { toggleGroupId } = useOpenGroups();
-  const { dagId = "", taskId } = useParams();
+  const { dagId = "", groupId, taskId } = useParams();
   const [searchParams] = useSearchParams();
 
   return nodes.map((node) => (
     <Box
-      bg={node.id === taskId ? "blue.muted" : undefined}
+      bg={node.id === taskId || node.id === groupId ? "blue.muted" : undefined}
       borderBottomWidth={1}
       borderColor={node.isGroup ? "border.emphasized" : "border.muted"}
       id={node.id.replaceAll(".", "-")}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
new file mode 100644
index 00000000000..32882e3e882
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
@@ -0,0 +1,89 @@
+/*!
+ * 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 { ReactFlowProvider } from "@xyflow/react";
+import { MdOutlineTask } from "react-icons/md";
+import { useParams } from "react-router-dom";
+
+import {
+  useDagRunServiceGetDagRun,
+  useDagServiceGetDagDetails,
+  useGridServiceGridData,
+} from "openapi/queries";
+import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
+import { isStatePending, useAutoRefresh } from "src/utils";
+
+import { Header } from "./Header";
+
+export const GroupTaskInstance = () => {
+  const { dagId = "", groupId = "", runId = "" } = useParams();
+  const refetchInterval = useAutoRefresh({ dagId });
+
+  const {
+    data: dag,
+    error: dagError,
+    isLoading: isDagLoading,
+  } = useDagServiceGetDagDetails({
+    dagId,
+  });
+
+  const { data: dagRun } = useDagRunServiceGetDagRun(
+    {
+      dagId,
+      dagRunId: runId,
+    },
+    undefined,
+    { enabled: runId !== "" },
+  );
+
+  // Filter grid data to get only a single dag run
+  const { data, error, isLoading } = useGridServiceGridData(
+    {
+      dagId,
+      limit: 1,
+      offset: 0,
+      runAfterGte: dagRun?.run_after,
+      runAfterLte: dagRun?.run_after,
+    },
+    undefined,
+    {
+      enabled: dagRun !== undefined,
+      refetchInterval: (query) =>
+        query.state.data?.dag_runs.some((dr) => isStatePending(dr.state)) && 
refetchInterval,
+    },
+  );
+
+  const taskInstance = data?.dag_runs
+    .find((dr) => dr.dag_run_id === runId)
+    ?.task_instances.find((ti) => ti.task_id === groupId);
+
+  const tabs = [{ icon: <MdOutlineTask />, label: "Task Instances", value: "" 
}];
+
+  return (
+    <ReactFlowProvider>
+      <DetailsLayout dag={dag} error={error ?? dagError} isLoading={isLoading 
|| isDagLoading} tabs={tabs}>
+        {taskInstance === undefined ? undefined : (
+          <Header
+            isRefreshing={Boolean(isStatePending(taskInstance.state) && 
Boolean(refetchInterval))}
+            taskInstance={taskInstance}
+          />
+        )}
+      </DetailsLayout>
+    </ReactFlowProvider>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
new file mode 100644
index 00000000000..aa0632b4493
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -0,0 +1,65 @@
+/*!
+ * 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 { Box } from "@chakra-ui/react";
+import type { ReactNode } from "react";
+import { MdOutlineTask } from "react-icons/md";
+
+import type { GridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { HeaderCard } from "src/components/HeaderCard";
+import Time from "src/components/Time";
+import { getDuration } from "src/utils";
+
+export const Header = ({
+  isRefreshing,
+  taskInstance,
+}: {
+  readonly isRefreshing?: boolean;
+  readonly taskInstance: GridTaskInstanceSummary;
+}) => {
+  const entries: Array<{ label: string; value: number | ReactNode | string }> 
= [];
+
+  if (taskInstance.child_states !== null) {
+    Object.entries(taskInstance.child_states).forEach(([state, count]) => {
+      if (count > 0) {
+        entries.push({ label: `Total ${state}`, value: count });
+      }
+    });
+  }
+  const stats = [
+    ...entries,
+    { label: "Earliest Start", value: <Time datetime={taskInstance.start_date} 
/> },
+    { label: "Latest End", value: <Time datetime={taskInstance.end_date} /> },
+    ...(Boolean(taskInstance.start_date)
+      ? [{ label: "Total Duration", value: 
getDuration(taskInstance.start_date, taskInstance.end_date) }]
+      : []),
+  ];
+
+  return (
+    <Box>
+      <HeaderCard
+        icon={<MdOutlineTask />}
+        isRefreshing={isRefreshing}
+        state={taskInstance.state}
+        stats={stats}
+        subTitle={<Time datetime={taskInstance.start_date} />}
+        title={taskInstance.task_id}
+      />
+    </Box>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
new file mode 100644
index 00000000000..f1f08181938
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * 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.
+ */
+
+export { GroupTaskInstance } from "./GroupTaskInstance";
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index a25e053dd2c..cf6e2987524 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -168,7 +168,7 @@ const taskInstanceColumns = (
 ];
 
 export const TaskInstances = () => {
-  const { dagId, runId, taskId } = useParams();
+  const { dagId, groupId, runId, taskId } = useParams();
   const [searchParams] = useSearchParams();
   const { setTableURLState, tableURLState } = useTableURLState();
   const { pagination, sorting } = tableURLState;
@@ -196,8 +196,8 @@ export const TaskInstances = () => {
       orderBy,
       startDateGte: startDate ?? undefined,
       state: hasFilteredState ? filteredState : undefined,
-      taskDisplayNamePattern: Boolean(taskDisplayNamePattern) ? 
taskDisplayNamePattern : undefined,
-      taskId: taskId ?? undefined,
+      taskDisplayNamePattern: groupId ?? taskDisplayNamePattern ?? undefined,
+      taskId: Boolean(groupId) ? undefined : taskId,
     },
     undefined,
     {
@@ -214,7 +214,7 @@ export const TaskInstances = () => {
         taskDisplayNamePattern={taskDisplayNamePattern}
       />
       <DataTable
-        columns={taskInstanceColumns(dagId, runId, taskId)}
+        columns={taskInstanceColumns(dagId, runId, Boolean(groupId) ? 
undefined : taskId)}
         data={data?.task_instances ?? []}
         errorMessage={<ErrorAlert error={error} />}
         initialState={tableURLState}
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index 77d55de10bf..39d7beed9a2 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -53,6 +53,7 @@ import { Variables } from "src/pages/Variables";
 import { XCom } from "src/pages/XCom";
 
 import { Configs } from "./pages/Configs";
+import { GroupTaskInstance } from "./pages/GroupTaskInstance";
 import { Security } from "./pages/Security";
 import { client } from "./queryClient";
 
@@ -170,6 +171,11 @@ export const routerConfig = [
         element: <MappedTaskInstance />,
         path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
       },
+      {
+        children: [{ element: <TaskInstances />, index: true }],
+        element: <GroupTaskInstance />,
+        path: "dags/:dagId/runs/:runId/tasks/group/:groupId",
+      },
       {
         children: taskInstanceRoutes,
         element: <TaskInstance />,

Reply via email to