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",
},