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 46da5ec33ad Add hitl required action count (#55546)
46da5ec33ad is described below

commit 46da5ec33ad355e7699fec3a10138932b3722059
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
---
 .../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}>

Reply via email to