This is an automated email from the ASF dual-hosted git repository. kaxilnaik pushed a commit to branch v3-1-test in repository https://gitbox.apache.org/repos/asf/airflow.git
commit ce5dc197fc72bf3b803346012be45b6c1dab0f24 Author: Brent Bovenzi <[email protected]> AuthorDate: Thu Sep 11 17:22:52 2025 -0600 Add hitl required action count (#55546) * Add pendign actions count to dag tabs * Add label to select (cherry picked from commit 46da5ec33ad355e7699fec3a10138932b3722059) --- .../airflow/ui/src/hooks/useRequiredActionTabs.ts | 127 +++++++++++++++++++++ airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx | 45 +++----- .../pages/HITLTaskInstances/HITLTaskInstances.tsx | 1 + airflow-core/src/airflow/ui/src/pages/Run/Run.tsx | 26 +---- .../src/airflow/ui/src/pages/Task/Task.tsx | 21 ++-- .../ui/src/pages/TaskInstance/TaskInstance.tsx | 44 ++----- 6 files changed, 165 insertions(+), 99 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/hooks/useRequiredActionTabs.ts b/airflow-core/src/airflow/ui/src/hooks/useRequiredActionTabs.ts new file mode 100644 index 00000000000..ddef7c64b08 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useRequiredActionTabs.ts @@ -0,0 +1,127 @@ +/*! + * 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 { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useHumanInTheLoopServiceGetHitlDetails } from "openapi/queries"; + +export type HITLQueryParams = { + dagId: string; + dagRunId?: string; + taskId?: string; + taskIdPattern?: string; +}; + +export type TabItem = { + icon: React.ReactNode; + label: string; + value: string; +}; + +export type UseRequiredActionTabsOptions = { + autoRedirect?: boolean; + refetchInterval?: number | false; +}; + +export const useRequiredActionTabs = ( + hitlParams: HITLQueryParams, + tabs: Array<TabItem>, + options: UseRequiredActionTabsOptions = {}, +) => { + const { t: translate } = useTranslation("hitl"); + const { autoRedirect = false, refetchInterval } = options; + const location = useLocation(); + const navigate = useNavigate(); + + const redirectPath = (() => { + const { dagId, dagRunId, taskId, taskIdPattern } = hitlParams; + + if (Boolean(dagId) && Boolean(dagRunId) && Boolean(taskId)) { + return `/dags/${dagId}/runs/${dagRunId}/tasks/${taskId}`; + } + if (Boolean(dagId) && Boolean(dagRunId)) { + return `/dags/${dagId}/runs/${dagRunId}`; + } + if (Boolean(dagId) && Boolean(taskIdPattern)) { + return `/dags/${dagId}/tasks/group/${taskIdPattern}`; + } + if (Boolean(dagId)) { + return `/dags/${dagId}`; + } + + // Fallback: remove /required_actions from current path + return location.pathname.replace("/required_actions", ""); + })(); + + const { data: hitlData, isLoading: isLoadingHitl } = useHumanInTheLoopServiceGetHitlDetails( + { + dagId: hitlParams.dagId, + dagRunId: hitlParams.dagRunId, + taskId: hitlParams.taskId, + taskIdPattern: hitlParams.taskIdPattern, + }, + undefined, + { + enabled: Boolean(hitlParams.dagId), + refetchInterval, + }, + ); + + const hasHitlData = (hitlData?.total_entries ?? 0) > 0; + const pendingActionsCount = + hitlData?.hitl_details.filter( + (hitl) => hitl.task_instance.state === "deferred" && !hitl.response_received, + ).length ?? 0; + + const processedTabs = tabs + .filter((tab) => { + // Hide required_actions tab if no HITL data exists + if (tab.value === "required_actions" && !hasHitlData) { + return false; + } + + return true; + }) + .map((tab) => { + // Update required_actions label with pending count + if (tab.value === "required_actions" && pendingActionsCount > 0) { + return { + ...tab, + label: translate("requiredActionCount", { count: pendingActionsCount }), + }; + } + + return tab; + }); + + useEffect(() => { + if (autoRedirect && !hasHitlData && !isLoadingHitl && location.pathname.includes("required_actions")) { + navigate(redirectPath); + } + }, [autoRedirect, hasHitlData, isLoadingHitl, location.pathname, navigate, redirectPath]); + + return { + hasHitlData, + hitlData, + isLoadingHitl, + pendingActionsCount, + tabs: processedTabs, + }; +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx index 063841c4510..ffcc37b4fb3 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Dag.tsx @@ -25,13 +25,10 @@ import { MdDetails, MdOutlineEventNote } from "react-icons/md"; import { RiArrowGoBackFill } from "react-icons/ri"; import { useParams } from "react-router-dom"; -import { - useDagServiceGetDagDetails, - useDagServiceGetLatestRunInfo, - useHumanInTheLoopServiceGetHitlDetails, -} from "openapi/queries"; +import { useDagServiceGetDagDetails, useDagServiceGetLatestRunInfo } from "openapi/queries"; import { TaskIcon } from "src/assets/TaskIcon"; import { usePluginTabs } from "src/hooks/usePluginTabs"; +import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useRefreshOnNewDagRuns } from "src/queries/useRefreshOnNewDagRuns"; import { isStatePending, useAutoRefresh } from "src/utils"; @@ -39,7 +36,7 @@ import { isStatePending, useAutoRefresh } from "src/utils"; import { Header } from "./Header"; export const Dag = () => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "" } = useParams(); // Get external views with dag destination @@ -73,30 +70,6 @@ export const Dag = () => { // pending state and new runs are initiated from other page useRefreshOnNewDagRuns(dagId, hasPendingRuns); - const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails( - { - dagId, - }, - undefined, - { - enabled: Boolean(dagId), - }, - ); - - const hasHitlTaskInstances = (hitlData?.total_entries ?? 0) > 0; - - const displayTabs = tabs.filter((tab) => { - if (dag?.timetable_summary === null && tab.value === "backfills") { - return false; - } - - if (tab.value === "required_actions" && !hasHitlTaskInstances) { - return false; - } - - return true; - }); - const { data: latestRun, error: runsError, @@ -118,6 +91,18 @@ export const Dag = () => { }, ); + const { tabs: processedTabs } = useRequiredActionTabs({ dagId }, tabs, { + refetchInterval: isStatePending(latestRun?.state) ? refetchInterval : false, + }); + + const displayTabs = processedTabs.filter((tab) => { + if (dag?.timetable_summary === null && tab.value === "backfills") { + return false; + } + + return true; + }); + return ( <ReactFlowProvider> <DetailsLayout error={error ?? runsError} isLoading={isLoading || isLoadingRuns} tabs={displayTabs}> diff --git a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx index 80481a86e9f..663b4615f29 100644 --- a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx +++ b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx @@ -174,6 +174,7 @@ export const HITLTaskInstances = () => { onValueChange={handleResponseChange} value={[responseReceived ?? "all"]} > + <Select.Label fontSize="xs">{translate("requiredActionState")}</Select.Label> <Select.Trigger isActive={Boolean(responseReceived)}> <Select.ValueText /> </Select.Trigger> diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx index d37db572979..eb97b8bf5c9 100644 --- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx @@ -22,15 +22,16 @@ import { FiCode, FiDatabase, FiUser } from "react-icons/fi"; import { MdDetails, MdOutlineEventNote, MdOutlineTask } from "react-icons/md"; import { useParams } from "react-router-dom"; -import { useDagRunServiceGetDagRun, useHumanInTheLoopServiceGetHitlDetails } from "openapi/queries"; +import { useDagRunServiceGetDagRun } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; +import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { isStatePending, useAutoRefresh } from "src/utils"; import { Header } from "./Header"; export const Run = () => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "", runId = "" } = useParams(); // Get external views with dag_run destination @@ -63,25 +64,8 @@ export const Run = () => { }, ); - const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails( - { - dagId, - dagRunId: runId, - }, - undefined, - { - enabled: Boolean(dagId && runId), - }, - ); - - const hasHitlTasksForRun = Boolean(hitlData?.hitl_details.length); - - const displayTabs = tabs.filter((tab) => { - if (tab.value === "required_actions" && !hasHitlTasksForRun) { - return false; - } - - return true; + const { tabs: displayTabs } = useRequiredActionTabs({ dagId, dagRunId: runId }, tabs, { + refetchInterval: isStatePending(dagRun?.state) ? refetchInterval : false, }); return ( diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx index cfeb856d82d..6fff8ab723e 100644 --- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx @@ -23,8 +23,9 @@ import { LuChartColumn } from "react-icons/lu"; import { MdOutlineEventNote, MdOutlineTask } from "react-icons/md"; import { useParams } from "react-router-dom"; -import { useTaskServiceGetTask, useHumanInTheLoopServiceGetHitlDetails } from "openapi/queries"; +import { useTaskServiceGetTask } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; +import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridStructure } from "src/queries/useGridStructure.ts"; import { getGroupTask } from "src/utils/groupTask"; @@ -33,7 +34,7 @@ import { GroupTaskHeader } from "./GroupTaskHeader"; import { Header } from "./Header"; export const Task = () => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "", groupId, runId, taskId } = useParams(); // Get external views with task destination @@ -59,24 +60,20 @@ export const Task = () => { const groupTask = getGroupTask(dagStructure, groupId); - const { data: hitlData } = useHumanInTheLoopServiceGetHitlDetails( + // Handle required action tabs with shared utility + const { tabs: processedTabs } = useRequiredActionTabs( { dagId, dagRunId: runId, taskId: Boolean(groupId) ? undefined : taskId, taskIdPattern: groupId, }, - undefined, - { - enabled: Boolean(dagId && (groupId !== undefined || taskId !== undefined)), - }, + tabs, ); - const hasHitlForTask = (hitlData?.total_entries ?? 0) > 0; - - const displayTabs = (groupId === undefined ? tabs : tabs.filter((tab) => tab.value !== "events")).filter( - (tab) => tab.value !== "required_actions" || hasHitlForTask, - ); + // Filter out events tab for group tasks + const displayTabs = + groupId === undefined ? processedTabs : processedTabs.filter((tab) => tab.value !== "events"); return ( <ReactFlowProvider> diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx index 39733cb140e..0eb684f0d89 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx @@ -17,18 +17,16 @@ * under the License. */ import { ReactFlowProvider } from "@xyflow/react"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { FiCode, FiDatabase, FiUser } from "react-icons/fi"; import { MdDetails, MdOutlineEventNote, MdOutlineTask, MdReorder, MdSyncAlt } from "react-icons/md"; import { PiBracketsCurlyBold } from "react-icons/pi"; -import { useLocation, useNavigate, useParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; -import { - useHumanInTheLoopServiceGetHitlDetails, - useTaskInstanceServiceGetMappedTaskInstance, -} from "openapi/queries"; +import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; +import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts"; import { isStatePending, useAutoRefresh } from "src/utils"; @@ -36,10 +34,8 @@ import { isStatePending, useAutoRefresh } from "src/utils"; import { Header } from "./Header"; export const TaskInstance = () => { - const { t: translate } = useTranslation("dag"); + const { t: translate } = useTranslation(["dag", "hitl"]); const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams(); - const navigate = useNavigate(); - const location = useLocation(); // Get external views with task_instance destination const externalTabs = usePluginTabs("task_instance"); @@ -80,21 +76,6 @@ export const TaskInstance = () => { const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId }); - const { data: hitlDetails, isLoading: isLoadingHitl } = useHumanInTheLoopServiceGetHitlDetails( - { - dagId, - dagRunId: runId, - taskId, - }, - undefined, - { - enabled: Boolean(dagId && runId), - refetchInterval, - }, - ); - - const hasHitlForTask = (hitlDetails?.total_entries ?? 0) > 0; - const taskInstanceSummary = gridTISummaries?.task_instances.find((ti) => ti.task_id === taskId); const taskCount = useMemo( () => @@ -119,20 +100,11 @@ export const TaskInstance = () => { ]; } - const displayTabs = newTabs.filter((tab) => { - if (tab.value === "required_actions" && !hasHitlForTask) { - return false; - } - - return true; + const { tabs: displayTabs } = useRequiredActionTabs({ dagId, dagRunId: runId, taskId }, newTabs, { + autoRedirect: true, + refetchInterval: isStatePending(taskInstance?.state) ? refetchInterval : false, }); - useEffect(() => { - if (!hasHitlForTask && !isLoadingHitl && location.pathname.includes("required_actions")) { - navigate(`/dags/${dagId}/runs/${runId}/tasks/${taskId}`); - } - }, [dagId, error, hasHitlForTask, isLoadingHitl, location.pathname, navigate, runId, taskId]); - return ( <ReactFlowProvider> <DetailsLayout error={error} isLoading={isLoading} tabs={displayTabs}>
