Repository: apache/airflow
Pull request #50309: Add task group detail for dag run selected case
Author: login: guan404ming
Labels: name: area:UI
description: Related to UI/UX. For Frontend Developers.
Created: 2025-05-07T16:26:13Z | Merged: 2025-05-08T15:49:05Z

<!--
 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.
 -->

<!--
Thank you for contributing! Please make sure that your code changes
are covered with tests. And in case of new features or big changes
remember to adjust the documentation.

Feel free to ping committers for the review!

In case of an existing issue, reference it using one of the following:

closes: #ISSUE
related: #ISSUE

How to write a good git commit message:
http://chris.beams.io/posts/git-commit/
-->

## Related Issue

#49741

## Why 

Currently we do have mapped task detail page but we don't have the group one.

## How

- implement for dag run selected case for now

## Note
Task group is a little bit more tricky than mapped one. Since tasks in group don't have same `task_id` like mapped ones, its task_id follow the format like `{parent_task_id}.{task_id}`, which means we couldn't fetch them barely using  the `task_id`. Thus, I use groupId in route to determine whether it is group task and I use taskNamePattern to fetch all child for it. Not sure if it's the best way to implement it so I decide to split into maybe 2-3 PRs make sure I keep on the right track and gather feedback at each stage. Please let me know if you have any suggestions or concerns about the approach. Thanks in advance!

test case: simple group

https://github.com/user-attachments/assets/3aa93272-e26d-474f-b57e-6ebb4ea4b7e3

test case: nested group

https://github.com/user-attachments/assets/6013d8e4-ffb5-4eb2-947a-1747320e9fa9

<!-- Please keep an empty line above the dashes. -->
---
**^ Add meaningful description above**
Read the **[Pull Request Guidelines](https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#pull-request-guidelines)** for more information.
In case of fundamental code changes, an Airflow Improvement Proposal ([AIP](https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals)) is needed.
In case of a new dependency, check compliance with the [ASF 3rd Party License Policy](https://www.apache.org/legal/resolved.html#category-x).
In case of backwards incompatible changes please leave a note in a newsfragment file, named `{pr_number}.significant.rst` or `{issue_number}.significant.rst`, in [airflow-core/newsfragments](https://github.com/apache/airflow/tree/main/airflow-core/newsfragments).



Diff:
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 278c6af3092c1..4a86c5eb9a5cd 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 08a17ea9e75b3..cd737af673a50 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 6010557922361..2061066a935e1 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 0000000000000..32882e3e88291
--- /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 0000000000000..aa0632b4493d1
--- /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 0000000000000..f1f0818193878
--- /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 df9ae5d2f0439..fa8aeac58670b 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 77d55de10bf7c..39d7beed9a22a 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 />,


Reviews:
user: login: bbovenzi
body: Great work taking this on!

Also, please check out the `isSelected` logic we have on the grid bar and task name to make sure the "crosshair" selection still works for Task Groups
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T17:09:57Z
user: login: bbovenzi
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T14:37:26Z

Review comments:
diff_hunk: @@ -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: "Start", value: <Time datetime={taskInstance.start_date} /> },
+    { label: "End", value: <Time datetime={taskInstance.end_date} /> },
+    ...(Boolean(taskInstance.start_date)
+      ? [{ label: "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} [${taskInstance.task_count}]`}
path: airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
user: login: bbovenzi
body: Let's not have the square brackets and task count for Task Groups. Those are specifically important for mapped tasks but not task groups
created_at: 2025-05-07T16:59:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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: "Start", value: <Time datetime={taskInstance.start_date} /> },
+    { label: "End", value: <Time datetime={taskInstance.end_date} /> },
+    ...(Boolean(taskInstance.start_date)
+      ? [{ label: "Duration", value: getDuration(taskInstance.start_date, taskInstance.end_date) }]
+      : []),
path: airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
user: login: bbovenzi
body: ```suggestion
    { 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) }]
      : []),
```
created_at: 2025-05-07T17:00:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+/*!
+ * 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 [${taskInstance?.task_count}]`, value: "" },
path: airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
user: login: bbovenzi
body: ```suggestion
    { icon: <MdOutlineTask />, label: 'Task Instances', value: "" },
```
created_at: 2025-05-07T17:01:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,9 +29,25 @@ 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} />;
+    const GroupTaskName = <TaskName isGroup={true} isMapped={isMapped} {...rest} />;
path: airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
user: login: bbovenzi
body: Let's just pass TaskName below. No need to make it a const first.
created_at: 2025-05-07T17:03:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,9 +29,25 @@ 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} />;
+    const GroupTaskName = <TaskName isGroup={true} isMapped={isMapped} {...rest} />;
path: airflow-core/src/airflow/ui/src/components/Graph/TaskLink.tsx
user: login: bbovenzi
body: We can now merge the two return RouterLink and instead just pass relevant props and add a `const pathname` variable we can pass.
created_at: 2025-05-07T17:09:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: guan404ming
created_at: 2025-05-07T18:09:45Z
body: Fixed~
![image](https://github.com/user-attachments/assets/c44e1ad4-beab-45ce-a4a2-d848fa730404)

author_association: MEMBER
user: login: bbovenzi
created_at: 2025-05-08T14:18:20Z
body: TaskName is getting the appropriate background but GridTIs do not. In `GridTI.tsx`, we need to also pull down the `groupId` from `useParams()` and use it in the comparisonat `bg={selectedTaskId === taskId ? "blue.muted" : undefined}``

<img width="612" alt="Screenshot 2025-05-08 at 10 16 14 AM" src="https://github.com/user-attachments/assets/224f9890-c385-4abf-b92a-04b61e02aa63" />

author_association: CONTRIBUTOR
user: login: bbovenzi
created_at: 2025-05-08T14:19:18Z
body: In another PR, we should create a Task Group across dag runs page too just like our Task across Runs page.
author_association: CONTRIBUTOR
user: login: guan404ming
created_at: 2025-05-08T14:23:47Z
body: Sorry I missed it and I've added it. Thanks for pointing out.
> In another PR, we should create a Task Group across dag runs page too just like our Task across Runs page.

I plan to implement it as a following PR.
author_association: MEMBER
user: login: guan404ming
created_at: 2025-05-08T15:56:15Z
body: Thanks! I would implement next one at the weekend.
author_association: MEMBER