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