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 7ccec1dcdcb UI: Add Details tab to the mapped task instance view 
(#68340)
7ccec1dcdcb is described below

commit 7ccec1dcdcb82bd151152852a4ca9d698711e88c
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Jun 11 18:47:05 2026 +0200

    UI: Add Details tab to the mapped task instance view (#68340)
    
    * UI: Add Details tab to the mapped task instance view
    
    Mapped tasks (the /mapped view opened from the Grid) now have a Details tab
    like normal task instances. It shows the task definition (operator, trigger
    rule, owner, retries, pool, queue, ...) and a per-state summary of the 
mapped
    instances, sourced from the grid TI summaries already loaded by the page.
    
    * UI: Auto-refresh mapped task summary while the run is running
    
    The mapped task Header and Details tab read their per-state summary from the
    grid TI summaries stream, which only polls while it knows a run is pending
    (via the run state passed in). The mapped view wasn't passing it, so the
    summary froze on the first fetch and showed stale counts on a running run.
    Pass the run state, mirroring the Graph and Grid views.
---
 .../airflow/ui/public/i18n/locales/en/common.json  |   6 +-
 .../src/airflow/ui/src/components/HeaderCard.tsx   |   8 +-
 .../ui/src/layouts/Details/DetailsLayout.tsx       |   6 +-
 .../ui/src/pages/MappedTaskInstance/Details.tsx    | 140 +++++++++++++++++++++
 .../ui/src/pages/MappedTaskInstance/Header.tsx     |  21 +++-
 .../MappedTaskInstance/MappedTaskInstance.tsx      |  17 ++-
 airflow-core/src/airflow/ui/src/router.tsx         |   6 +-
 7 files changed, 189 insertions(+), 15 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 8eea401e69a..c949d619b6c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -180,6 +180,7 @@
     "placeholder": "Add a note...",
     "taskInstance": "Task Instance Note"
   },
+  "overallStatus": "Overall Status",
   "partitionedDagRun_one": "Partitioned Dag Run",
   "partitionedDagRun_other": "Partitioned Dag Runs",
   "partitionedDagRunDetail": {
@@ -267,10 +268,13 @@
     "updatedAt": "Updated at"
   },
   "task": {
+    "dependsOnPast": "Depends on Past",
     "documentation": "Task Documentation",
     "lastInstance": "Last Instance",
     "operator": "Operator",
-    "triggerRule": "Trigger Rule"
+    "retries": "Retries",
+    "triggerRule": "Trigger Rule",
+    "waitForDownstream": "Wait for Downstream"
   },
   "task_one": "Task",
   "task_other": "Tasks",
diff --git a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx 
b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
index 62b2c86c605..7908076bc4d 100644
--- a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
+++ b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
@@ -30,7 +30,7 @@ type Props = {
   readonly actions?: ReactNode;
   readonly icon: ReactNode;
   readonly state?: TaskInstanceState | null;
-  readonly stats: Array<{ label: string; value: ReactNode | string }>;
+  readonly stats: Array<{ key?: string; label: string; value: ReactNode | 
string }>;
   readonly subTitle?: ReactNode | string;
   readonly title: ReactNode | string;
 };
@@ -61,9 +61,9 @@ export const HeaderCard = ({ actions, icon, state, stats, 
subTitle, title }: Pro
         </Flex>
 
         <HStack alignItems="flex-start" flexWrap="wrap" gap={5} 
justifyContent="space-between" my={2}>
-          {stats.map(({ label, value }) => (
-            <GridItem key={label}>
-              <Stat label={label}>{value}</Stat>
+          {stats.map((stat) => (
+            <GridItem key={stat.key ?? stat.label}>
+              <Stat label={stat.label}>{stat.value}</Stat>
             </GridItem>
           ))}
         </HStack>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 034d51e0c37..736f81828eb 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -88,10 +88,12 @@ const SharedScrollBox = ({
 type Props = {
   readonly error?: unknown;
   readonly isLoading?: boolean;
+  /** Value exposed to the active tab via ``useOutletContext`` (so tabs can 
reuse the parent's data). */
+  readonly outletContext?: unknown;
   readonly tabs: Array<NavTab>;
 } & PropsWithChildren;
 
-export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => {
+export const DetailsLayout = ({ children, error, isLoading, outletContext, 
tabs }: Props) => {
   const { t: translate } = useTranslation();
   const { dagId = "", runId } = useParams();
   const { data: dag } = useDagServiceGetDag({ dagId });
@@ -406,7 +408,7 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
                       <ProgressBar size="xs" visibility={isLoading ? "visible" 
: "hidden"} />
                       <NavTabs tabs={tabs} />
                       <Box flexGrow={1} overflow="auto" px={2}>
-                        <Outlet />
+                        <Outlet context={outletContext} />
                       </Box>
                     </Box>
                   </Panel>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx
new file mode 100644
index 00000000000..0876f21a590
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Details.tsx
@@ -0,0 +1,140 @@
+/*!
+ * 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, Flex, Table } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { useOutletContext, useParams } from "react-router-dom";
+
+import { useTaskServiceGetTask } from "openapi/queries";
+import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+import { getDuration } from "src/utils";
+
+export const Details = () => {
+  const { dagId = "", taskId = "" } = useParams();
+  const { t: translate } = useTranslation("common");
+
+  // The aggregate summary (per-state counts, dates) is streamed once by the 
parent page and
+  // shared through the router outlet, so this tab does not re-open the TI 
summaries stream.
+  const taskInstance = useOutletContext<LightGridTaskInstanceSummary | 
undefined>();
+
+  const { data: task } = useTaskServiceGetTask({ dagId, taskId }, undefined, { 
enabled: Boolean(taskId) });
+
+  const childStates = Object.entries(taskInstance?.child_states ?? {});
+
+  return (
+    <Box p={2}>
+      <Table.Root striped>
+        <Table.Body>
+          <Table.Row>
+            <Table.Cell>{translate("overallStatus")}</Table.Cell>
+            <Table.Cell>
+              <Flex alignItems="center" gap={1}>
+                <StateBadge state={taskInstance?.state} />
+                {taskInstance?.state ?? translate("states.no_status")}
+              </Flex>
+            </Table.Cell>
+          </Table.Row>
+          {childStates.map(([state, count]) => (
+            <Table.Row key={state}>
+              <Table.Cell>{translate("total", { state: 
translate(`states.${state}`) })}</Table.Cell>
+              <Table.Cell>
+                <Flex alignItems="center" gap={2}>
+                  <Box
+                    bg={`${state}.solid`}
+                    border="1px solid"
+                    borderColor="border.emphasized"
+                    borderRadius="2px"
+                    height="10px"
+                    width="10px"
+                  />
+                  {count}
+                </Flex>
+              </Table.Cell>
+            </Table.Row>
+          ))}
+          <Table.Row>
+            <Table.Cell>{translate("taskId")}</Table.Cell>
+            <Table.Cell>{taskId}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("task.operator")}</Table.Cell>
+            <Table.Cell>{task?.operator_name}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("task.triggerRule")}</Table.Cell>
+            <Table.Cell>{task?.trigger_rule}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("dagDetails.owner")}</Table.Cell>
+            <Table.Cell>{task?.owner}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("task.retries")}</Table.Cell>
+            <Table.Cell>{task?.retries}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.pool")}</Table.Cell>
+            <Table.Cell>{task?.pool}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.poolSlots")}</Table.Cell>
+            <Table.Cell>{task?.pool_slots}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.queue")}</Table.Cell>
+            <Table.Cell>{task?.queue}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.priorityWeight")}</Table.Cell>
+            <Table.Cell>{task?.priority_weight}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("task.dependsOnPast")}</Table.Cell>
+            <Table.Cell>{task === undefined ? undefined : 
String(task.depends_on_past)}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("task.waitForDownstream")}</Table.Cell>
+            <Table.Cell>{task === undefined ? undefined : 
String(task.wait_for_downstream)}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("startDate")}</Table.Cell>
+            <Table.Cell>
+              <Time datetime={taskInstance?.min_start_date} />
+            </Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("endDate")}</Table.Cell>
+            <Table.Cell>
+              <Time datetime={taskInstance?.max_end_date} />
+            </Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("duration")}</Table.Cell>
+            <Table.Cell>{getDuration(taskInstance?.min_start_date, 
taskInstance?.max_end_date)}</Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.dagVersion")}</Table.Cell>
+            <Table.Cell>{taskInstance?.dag_version_number}</Table.Cell>
+          </Table.Row>
+        </Table.Body>
+      </Table.Root>
+    </Box>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
index f84de17c164..9202713363b 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box } from "@chakra-ui/react";
+import { Box, HStack } from "@chakra-ui/react";
 import type { ReactNode } from "react";
 import { useTranslation } from "react-i18next";
 import { MdOutlineTask } from "react-icons/md";
@@ -31,13 +31,26 @@ import { getDuration } from "src/utils";
 export const Header = ({ taskInstance }: { readonly taskInstance: 
LightGridTaskInstanceSummary }) => {
   const { dagId = "", runId = "" } = useParams();
   const { t: translate } = useTranslation();
-  const entries: Array<{ label: string; value: number | ReactNode | string }> 
= [];
+  const entries: Array<{ key?: string; label: string; value: number | 
ReactNode | string }> = [];
   let taskCount: number = 0;
 
   Object.entries(taskInstance.child_states ?? {}).forEach(([state, count]) => {
     entries.push({
-      label: translate("total", { state: 
translate(`states.${state.toLowerCase()}`) }),
-      value: count,
+      key: state,
+      label: translate("total", { state: translate(`states.${state}`) }),
+      value: (
+        <HStack gap={2}>
+          <Box
+            bg={`${state}.solid`}
+            border="1px solid"
+            borderColor="border.emphasized"
+            borderRadius="2px"
+            height="10px"
+            width="10px"
+          />
+          {count}
+        </HStack>
+      ),
     });
     taskCount += count;
   });
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
index 6302254eafa..2bc1daa3ed6 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
@@ -18,9 +18,10 @@
  */
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
-import { MdOutlineTask } from "react-icons/md";
+import { MdDetails, MdOutlineTask } from "react-icons/md";
 import { useParams } from "react-router-dom";
 
+import { useDagRunServiceGetDagRun } from "openapi/queries";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
 
@@ -29,7 +30,16 @@ import { Header } from "./Header";
 export const MappedTaskInstance = () => {
   const { dagId = "", runId = "", taskId = "" } = useParams();
   const { t: translate } = useTranslation("dag");
-  const { summariesByRunId } = useGridTiSummariesStream({ dagId, runIds: runId 
? [runId] : [] });
+  // Pass the run state so the summaries stream keeps auto-refreshing while 
the run is running;
+  // without it the Header and Details tab would freeze on the first fetch.
+  const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId: runId 
}, undefined, {
+    enabled: Boolean(runId),
+  });
+  const { summariesByRunId } = useGridTiSummariesStream({
+    dagId,
+    runIds: runId ? [runId] : [],
+    states: dagRun ? [dagRun.state] : undefined,
+  });
   const gridTISummaries = summariesByRunId.get(runId);
 
   const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id 
=== taskId);
@@ -41,11 +51,12 @@ export const MappedTaskInstance = () => {
 
   const tabs = [
     { icon: <MdOutlineTask />, label: `${translate("tabs.taskInstances")} 
[${taskCount}]`, value: "" },
+    { icon: <MdDetails />, label: translate("tabs.details"), value: "details" 
},
   ];
 
   return (
     <ReactFlowProvider>
-      <DetailsLayout tabs={tabs}>
+      <DetailsLayout outletContext={taskInstance} tabs={tabs}>
         {taskInstance === undefined ? undefined : <Header 
taskInstance={taskInstance} />}
       </DetailsLayout>
     </ReactFlowProvider>
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index d315443a56f..b6498c34c48 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -47,6 +47,7 @@ import { GroupTaskInstance } from 
"src/pages/GroupTaskInstance";
 import { HITLTaskInstances } from "src/pages/HITLTaskInstances";
 import { Jobs } from "src/pages/Jobs";
 import { MappedTaskInstance } from "src/pages/MappedTaskInstance";
+import { Details as MappedTaskInstanceDetails } from 
"src/pages/MappedTaskInstance/Details";
 import { Plugins } from "src/pages/Plugins";
 import { Pools } from "src/pages/Pools";
 import { Providers } from "src/pages/Providers";
@@ -216,7 +217,10 @@ export const routerConfig = [
         path: "dags/:dagId/runs/:runId/tasks/:taskId",
       },
       {
-        children: [{ element: <TaskInstances />, index: true }],
+        children: [
+          { element: <TaskInstances />, index: true },
+          { element: <MappedTaskInstanceDetails />, path: "details" },
+        ],
         element: <MappedTaskInstance />,
         path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
       },

Reply via email to