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 8eea401e69a81..c949d619b6ce0 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 62b2c86c605db..7908076bc4d9e 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
- {stats.map(({ label, value }) => (
-
- {value}
+ {stats.map((stat) => (
+
+ {stat.value}
))}
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 034d51e0c379b..736f81828eb1d 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;
} & 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) => {
-
+
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 0000000000000..0876f21a590df
--- /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();
+
+ const { data: task } = useTaskServiceGetTask({ dagId, taskId }, undefined, { enabled: Boolean(taskId) });
+
+ const childStates = Object.entries(taskInstance?.child_states ?? {});
+
+ return (
+
+
+
+
+ {translate("overallStatus")}
+
+
+
+ {taskInstance?.state ?? translate("states.no_status")}
+
+
+
+ {childStates.map(([state, count]) => (
+
+ {translate("total", { state: translate(`states.${state}`) })}
+
+
+
+ {count}
+
+
+
+ ))}
+
+ {translate("taskId")}
+ {taskId}
+
+
+ {translate("task.operator")}
+ {task?.operator_name}
+
+
+ {translate("task.triggerRule")}
+ {task?.trigger_rule}
+
+
+ {translate("dagDetails.owner")}
+ {task?.owner}
+
+
+ {translate("task.retries")}
+ {task?.retries}
+
+
+ {translate("taskInstance.pool")}
+ {task?.pool}
+
+
+ {translate("taskInstance.poolSlots")}
+ {task?.pool_slots}
+
+
+ {translate("taskInstance.queue")}
+ {task?.queue}
+
+
+ {translate("taskInstance.priorityWeight")}
+ {task?.priority_weight}
+
+
+ {translate("task.dependsOnPast")}
+ {task === undefined ? undefined : String(task.depends_on_past)}
+
+
+ {translate("task.waitForDownstream")}
+ {task === undefined ? undefined : String(task.wait_for_downstream)}
+
+
+ {translate("startDate")}
+
+
+
+
+
+ {translate("endDate")}
+
+
+
+
+
+ {translate("duration")}
+ {getDuration(taskInstance?.min_start_date, taskInstance?.max_end_date)}
+
+
+ {translate("taskInstance.dagVersion")}
+ {taskInstance?.dag_version_number}
+
+
+
+
+ );
+};
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 f84de17c164e7..9202713363b83 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: (
+
+
+ {count}
+
+ ),
});
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 6302254eafa3b..2bc1daa3ed60c 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: , label: `${translate("tabs.taskInstances")} [${taskCount}]`, value: "" },
+ { icon: , label: translate("tabs.details"), value: "details" },
];
return (
-
+
{taskInstance === undefined ? undefined : }
diff --git a/airflow-core/src/airflow/ui/src/router.tsx b/airflow-core/src/airflow/ui/src/router.tsx
index d315443a56fda..b6498c34c48d4 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: , index: true }],
+ children: [
+ { element: , index: true },
+ { element: , path: "details" },
+ ],
element: ,
path: "dags/:dagId/runs/:runId/tasks/:taskId/mapped",
},