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