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) {