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 2a1a9d13ca5 UI: Add Clear All Mapped Tasks button  (#65813)
2a1a9d13ca5 is described below

commit 2a1a9d13ca5538bae2594a4ed7ce0f071ebdc9bf
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Wed Apr 29 19:15:15 2026 +0200

    UI: Add Clear All Mapped Tasks button  (#65813)
    
    * feat(ui): add Clear All Mapped Tasks button
    
    Picks up the feature from stale PR apache/airflow#60591 (author: Vedant
    Madane). Closes #60460.
    
    Surgical extension of the existing ClearTaskInstance* components:
    
    - ClearTaskInstanceDialog gains an 'allMapped' mode. When set,
      dagId/dagRunId/taskId are taken from props instead of taskInstance,
      task_ids uses the string form ([taskId]) to target every mapped TI,
      and the note patch omits map_index so the backend applies it to all
      mapped instances at once. Existing single-TI callers are unchanged.
    - ClearTaskInstanceButton gains the same 'allMapped' pass-through.
    - ClearTaskInstanceConfirmationDialog picks the string or tuple form
      for task_ids depending on whether mapIndex is present.
    - New button rendered on the MappedTaskInstance header and next to the
      TaskInstances filter when the route identifies a specific task.
    
    * Address some review comments
---
 .../airflow/ui/public/i18n/locales/en/dags.json    |  5 ++
 .../Clear/TaskInstance/ClearTaskInstanceButton.tsx | 47 +++++++++++--
 .../ClearTaskInstanceConfirmationDialog.tsx        |  5 +-
 .../Clear/TaskInstance/ClearTaskInstanceDialog.tsx | 79 ++++++++++++++++------
 .../ui/src/pages/MappedTaskInstance/Header.tsx     | 12 ++++
 .../airflow/ui/src/queries/usePatchTaskInstance.ts | 28 +++++++-
 6 files changed, 144 insertions(+), 32 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index b12b5767898..dc403907457 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -34,6 +34,11 @@
       "error": "Failed to clear {{type}}",
       "title": "Clear {{type}}"
     },
+    "clearAllMapped": {
+      "button": "Clear All Mapped Tasks",
+      "buttonTooltip": "Press shift+c to clear all mapped task instances",
+      "title": "Clear All Mapped Task Instances"
+    },
     "confirmationDialog": {
       "description": "Task is currently in a {{state}} state started by user 
{{user}} at {{time}}. \nThe user is unable to clear this task until it is done 
running or a user unchecks the \"Prevent rerun if task is running\" option in 
the clear task dialog.",
       "title": "Cannot Clear Task Instance"
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
index 836d239c1c5..6a6b40f8024 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
@@ -28,17 +28,27 @@ import { Tooltip } from "src/components/ui";
 import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog";
 
 type Props = {
+  // Clear all mapped TIs of a task at once. When set, dagId/dagRunId/taskId
+  // are required; taskInstance/groupTaskInstance must be absent.
+  readonly allMapped?: boolean;
+  readonly dagId?: string;
+  readonly dagRunId?: string;
   readonly groupTaskInstance?: LightGridTaskInstanceSummary;
   readonly isHotkeyEnabled?: boolean;
   // Optional: allow parent to handle opening a stable, page-level dialog
   readonly onOpen?: (ti: LightGridTaskInstanceSummary | TaskInstanceResponse) 
=> void;
+  readonly taskId?: string;
   readonly taskInstance?: TaskInstanceResponse;
 };
 
 const ClearTaskInstanceButton = ({
+  allMapped = false,
+  dagId,
+  dagRunId,
   groupTaskInstance,
   isHotkeyEnabled = false,
   onOpen,
+  taskId,
   taskInstance,
 }: Props) => {
   const { onClose, onOpen: onOpenInternal, open } = useDisclosure();
@@ -65,16 +75,22 @@ const ClearTaskInstanceButton = ({
       <Tooltip
         closeDelay={100}
         content={
-          isHotkeyEnabled
-            ? translate("dags:runAndTaskActions.clear.buttonTooltip")
-            : translate("dags:runAndTaskActions.clear.button", { type: 
translate("taskInstance_one") })
+          allMapped
+            ? isHotkeyEnabled
+              ? 
translate("dags:runAndTaskActions.clearAllMapped.buttonTooltip")
+              : translate("dags:runAndTaskActions.clearAllMapped.button")
+            : isHotkeyEnabled
+              ? translate("dags:runAndTaskActions.clear.buttonTooltip")
+              : translate("dags:runAndTaskActions.clear.button", { type: 
translate("taskInstance_one") })
         }
         openDelay={100}
       >
         <IconButton
-          aria-label={translate("dags:runAndTaskActions.clear.button", {
-            type: translate("taskInstance_one"),
-          })}
+          aria-label={
+            allMapped
+              ? translate("dags:runAndTaskActions.clearAllMapped.button")
+              : translate("dags:runAndTaskActions.clear.button", { type: 
translate("taskInstance_one") })
+          }
           colorPalette="brand"
           onClick={() => (onOpen && selectedInstance ? 
onOpen(selectedInstance) : onOpenInternal())}
           size="md"
@@ -88,7 +104,24 @@ const ClearTaskInstanceButton = ({
         <ClearGroupTaskInstanceDialog onClose={onClose} open={open} 
taskInstance={groupTaskInstance} />
       ) : undefined}
 
-      {useInternalDialog && open && !isGroup && taskInstance ? (
+      {useInternalDialog &&
+      open &&
+      !isGroup &&
+      allMapped &&
+      dagId !== undefined &&
+      dagRunId !== undefined &&
+      taskId !== undefined ? (
+        <ClearTaskInstanceDialog
+          allMapped
+          dagId={dagId}
+          dagRunId={dagRunId}
+          onClose={onClose}
+          open={open}
+          taskId={taskId}
+        />
+      ) : undefined}
+
+      {useInternalDialog && open && !isGroup && !allMapped && taskInstance ? (
         <ClearTaskInstanceDialog onClose={onClose} open={open} 
taskInstance={taskInstance} />
       ) : undefined}
     </>
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
index f9f3dcfe1b3..9cb0f5c644a 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceConfirmationDialog.tsx
@@ -67,7 +67,10 @@ const ClearTaskInstanceConfirmationDialog = ({
       include_past: dagDetails?.past,
       include_upstream: dagDetails?.upstream,
       only_failed: dagDetails?.onlyFailed,
-      task_ids: [[dagDetails?.taskId ?? "", dagDetails?.mapIndex ?? 0]],
+      task_ids:
+        dagDetails?.mapIndex === undefined
+          ? [dagDetails?.taskId ?? ""]
+          : [[dagDetails.taskId, dagDetails.mapIndex]],
     },
   });
 
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
index c7562083c05..391951b0f4e 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
@@ -34,21 +34,47 @@ import { isStatePending, useAutoRefresh } from "src/utils";
 
 import ClearTaskInstanceConfirmationDialog from 
"./ClearTaskInstanceConfirmationDialog";
 
+// Discriminated union: callers pass either `allMapped: true` together with
+// `dagId`/`dagRunId`/`taskId` (clears every mapped TI of the task), or a full
+// `taskInstance` (clears that single TI and reads its display fields). The
+// two variants are mutually exclusive at the type level — no defensive
+// runtime fallback chains needed in the body.
 type Props = {
   readonly onClose: () => void;
   readonly open: boolean;
-  readonly taskInstance: TaskInstanceResponse;
-};
+} & (
+  | {
+      readonly allMapped: true;
+      readonly dagId: string;
+      readonly dagRunId: string;
+      readonly taskId: string;
+    }
+  | {
+      readonly allMapped?: false;
+      readonly taskInstance: TaskInstanceResponse;
+    }
+);
 
-const ClearTaskInstanceDialog = ({ onClose: onCloseDialog, open: openDialog, 
taskInstance }: Props) => {
-  const taskId = taskInstance.task_id;
-  const mapIndex = taskInstance.map_index;
+// react/destructuring-assignment expects every prop access via signature
+// destructure, but TypeScript's discriminated-union narrowing needs the union
+// kept whole on the parameter — otherwise the `props.allMapped` discriminator
+// is severed from `props.dagId` / `props.taskInstance`. Disable the rule for
+// the parameter and the prop extraction; everything after uses local
+// variables.
+/* eslint-disable react/destructuring-assignment */
+const ClearTaskInstanceDialog = (props: Props) => {
+  const allMapped = props.allMapped === true;
+  const dagId = props.allMapped ? props.dagId : props.taskInstance.dag_id;
+  const dagRunId = props.allMapped ? props.dagRunId : 
props.taskInstance.dag_run_id;
+  const taskId = props.allMapped ? props.taskId : props.taskInstance.task_id;
+  const mapIndex: number | undefined = props.allMapped ? undefined : 
props.taskInstance.map_index;
+  const taskInstance: TaskInstanceResponse | undefined = props.allMapped ? 
undefined : props.taskInstance;
+  const onCloseDialog = props.onClose;
+  const openDialog = props.open;
+  /* eslint-enable react/destructuring-assignment */
   const { t: translate } = useTranslation();
   const { onClose, onOpen, open } = useDisclosure();
 
-  const dagId = taskInstance.dag_id;
-  const dagRunId = taskInstance.dag_run_id;
-
   const { isPending, mutate } = useClearTaskInstances({
     dagId,
     dagRunId,
@@ -65,11 +91,10 @@ const ClearTaskInstanceDialog = ({ onClose: onCloseDialog, 
open: openDialog, tas
   const [runOnLatestVersion, setRunOnLatestVersion] = useState(false);
   const [preventRunningTask, setPreventRunningTask] = useState(true);
 
-  const [note, setNote] = useState<string | null>(taskInstance.note);
+  const [note, setNote] = useState<string | null>(taskInstance?.note ?? null);
   const { isPending: isPendingPatchDagRun, mutate: mutatePatchTaskInstance } = 
usePatchTaskInstance({
     dagId,
     dagRunId,
-    mapIndex,
     taskId,
   });
 
@@ -98,7 +123,7 @@ const ClearTaskInstanceDialog = ({ onClose: onCloseDialog, 
open: openDialog, tas
       include_upstream: upstream,
       only_failed: onlyFailed,
       run_on_latest_version: runOnLatestVersion,
-      task_ids: [[taskId, mapIndex]],
+      task_ids: allMapped ? [taskId] : [[taskId, mapIndex as number]],
     },
   });
 
@@ -107,11 +132,13 @@ const ClearTaskInstanceDialog = ({ onClose: 
onCloseDialog, open: openDialog, tas
     total_entries: 0,
   };
 
-  // Check if bundle versions are different
+  // Check if bundle versions are different (not applicable for allMapped —
+  // mapped TIs may be on several versions)
   const currentDagBundleVersion = dagDetails?.bundle_version;
-  const taskInstanceDagVersionBundleVersion = 
taskInstance.dag_version?.bundle_version;
+  const taskInstanceDagVersionBundleVersion = 
taskInstance?.dag_version?.bundle_version;
   const bundleVersionsDiffer = currentDagBundleVersion !== 
taskInstanceDagVersionBundleVersion;
   const shouldShowBundleVersionOption =
+    !allMapped &&
     bundleVersionsDiffer &&
     taskInstanceDagVersionBundleVersion !== null &&
     taskInstanceDagVersionBundleVersion !== "";
@@ -124,12 +151,20 @@ const ClearTaskInstanceDialog = ({ onClose: 
onCloseDialog, open: openDialog, tas
             <VStack align="start" gap={4}>
               <Heading size="xl">
                 <strong>
-                  {translate("dags:runAndTaskActions.clear.title", {
-                    type: translate("taskInstance_one"),
-                  })}
+                  {allMapped
+                    ? translate("dags:runAndTaskActions.clearAllMapped.title")
+                    : translate("dags:runAndTaskActions.clear.title", {
+                        type: translate("taskInstance_one"),
+                      })}
                   :
                 </strong>{" "}
-                {taskInstance.task_display_name} <Time 
datetime={taskInstance.start_date} />
+                {allMapped ? (
+                  taskId
+                ) : (
+                  <>
+                    {taskInstance?.task_display_name} <Time 
datetime={taskInstance?.start_date} />
+                  </>
+                )}
               </Heading>
             </VStack>
           </Dialog.Header>
@@ -144,12 +179,12 @@ const ClearTaskInstanceDialog = ({ onClose: 
onCloseDialog, open: openDialog, tas
                 onChange={setSelectedOptions}
                 options={[
                   {
-                    disabled: taskInstance.logical_date === null,
+                    disabled: allMapped || taskInstance?.logical_date === null,
                     label: translate("dags:runAndTaskActions.options.past"),
                     value: "past",
                   },
                   {
-                    disabled: taskInstance.logical_date === null,
+                    disabled: allMapped || taskInstance?.logical_date === null,
                     label: translate("dags:runAndTaskActions.options.future"),
                     value: "future",
                   },
@@ -227,15 +262,15 @@ const ClearTaskInstanceDialog = ({ onClose: 
onCloseDialog, open: openDialog, tas
                 include_upstream: upstream,
                 only_failed: onlyFailed,
                 run_on_latest_version: runOnLatestVersion,
-                task_ids: [[taskId, mapIndex]],
+                task_ids: allMapped ? [taskId] : [[taskId, mapIndex as 
number]],
                 ...(preventRunningTask ? { prevent_running_task: true } : {}),
               },
             });
-            if (note !== taskInstance.note) {
+            if (note !== (taskInstance?.note ?? null)) {
               mutatePatchTaskInstance({
                 dagId,
                 dagRunId,
-                mapIndex,
+                ...(allMapped ? {} : { mapIndex }),
                 requestBody: { note },
                 taskId,
               });
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 97c86581a23..592f36e1077 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
@@ -20,13 +20,16 @@ import { Box } from "@chakra-ui/react";
 import type { ReactNode } from "react";
 import { useTranslation } from "react-i18next";
 import { MdOutlineTask } from "react-icons/md";
+import { useParams } from "react-router-dom";
 
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { ClearTaskInstanceButton } from "src/components/Clear";
 import { HeaderCard } from "src/components/HeaderCard";
 import Time from "src/components/Time";
 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 }> 
= [];
   let taskCount: number = 0;
@@ -55,6 +58,15 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
   return (
     <Box>
       <HeaderCard
+        actions={
+          <ClearTaskInstanceButton
+            allMapped
+            dagId={dagId}
+            dagRunId={runId}
+            isHotkeyEnabled
+            taskId={taskInstance.task_id}
+          />
+        }
         icon={<MdOutlineTask />}
         state={taskInstance.state}
         stats={stats}
diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts 
b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
index 6b7a2a1d1ca..33e1f689e4b 100644
--- a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
+++ b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts
@@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next";
 
 import {
   UseTaskInstanceServiceGetMappedTaskInstanceKeyFn,
+  useTaskInstanceServiceGetMappedTaskInstanceKey,
   UseTaskInstanceServiceGetTaskInstanceKeyFn,
   useTaskInstanceServiceGetTaskInstancesKey,
   useTaskInstanceServicePatchTaskInstance,
@@ -40,7 +41,7 @@ export const usePatchTaskInstance = ({
 }: {
   dagId: string;
   dagRunId: string;
-  mapIndex: number;
+  mapIndex?: number;
   onSuccess?: () => void;
   taskId: string;
 }) => {
@@ -61,15 +62,38 @@ export const usePatchTaskInstance = ({
   const onSuccessFn = async () => {
     const queryKeys = [
       UseTaskInstanceServiceGetTaskInstanceKeyFn({ dagId, dagRunId, taskId }),
-      UseTaskInstanceServiceGetMappedTaskInstanceKeyFn({ dagId, dagRunId, 
mapIndex, taskId }),
       [useTaskInstanceServiceGetTaskInstancesKey],
       [usePatchTaskInstanceDryRunKey, dagId, dagRunId, { mapIndex, taskId }],
       [useClearTaskInstancesDryRunKey, dagId],
     ];
 
+    if (mapIndex !== undefined) {
+      queryKeys.push(UseTaskInstanceServiceGetMappedTaskInstanceKeyFn({ dagId, 
dagRunId, mapIndex, taskId }));
+    }
+
     await Promise.all([
       ...gridQueryKeys(dagId).map((key) => queryClient.invalidateQueries({ 
queryKey: key })),
       ...queryKeys.map((key) => queryClient.invalidateQueries({ queryKey: key 
})),
+      // Wildcard match when patching every mapped TI (mapIndex undefined):
+      // invalidate the mapped-TI cache for every map_index of this task.
+      // The mapped-TI key embeds its params as a single object, so prefix
+      // matching on a partial key doesn't catch them — match via predicate.
+      ...(mapIndex === undefined
+        ? [
+            queryClient.invalidateQueries({
+              predicate: ({ queryKey }) => {
+                if (queryKey[0] !== 
useTaskInstanceServiceGetMappedTaskInstanceKey) {
+                  return false;
+                }
+                const params = queryKey[1] as
+                  | { dagId?: string; dagRunId?: string; taskId?: string }
+                  | undefined;
+
+                return params?.dagId === dagId && params.dagRunId === dagRunId 
&& params.taskId === taskId;
+              },
+            }),
+          ]
+        : []),
     ]);
 
     if (onSuccess) {

Reply via email to