This is an automated email from the ASF dual-hosted git repository.

choo121600 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 31fcbf65cc2 UI: Add checkboxes to clear task instances (#68029)
31fcbf65cc2 is described below

commit 31fcbf65cc21b88af9e4404b8495e56102746bd8
Author: suhaas-vaddadi <[email protected]>
AuthorDate: Fri Jun 5 21:51:38 2026 -0700

    UI: Add checkboxes to clear task instances (#68029)
---
 .../components/ActionAccordion/ActionAccordion.tsx |  11 +-
 .../ui/src/components/ActionAccordion/columns.tsx  |  61 +++++++-
 .../ClearTaskInstanceConfirmationDialog.tsx        |  12 +-
 .../Clear/TaskInstance/ClearTaskInstanceDialog.tsx | 162 ++++++++++++++++-----
 4 files changed, 198 insertions(+), 48 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
 
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
index 10cff141cbd..0a956162954 100644
--- 
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx
@@ -29,24 +29,27 @@ import ReactMarkdown from "src/components/ReactMarkdown";
 import { Accordion } from "src/components/ui";
 
 import { DataTable } from "../DataTable";
-import { getColumns } from "./columns";
+import { getColumns, type RowSelection } from "./columns";
 
 type Props = {
   readonly affectedTasks?: TaskInstanceCollectionResponse;
   readonly groupByRunId?: boolean;
   readonly note: DAGRunResponse["note"];
+  readonly selection?: RowSelection;
   readonly setNote: (value: string) => void;
 };
 
 const TasksTable = ({
   noRowsMessage,
+  selection,
   tasks,
 }: {
   readonly noRowsMessage: string;
+  readonly selection?: RowSelection;
   readonly tasks: Array<TaskInstanceResponse>;
 }) => {
   const { t: translate } = useTranslation();
-  const columns = getColumns(translate);
+  const columns = getColumns(translate, selection);
 
   return (
     <DataTable
@@ -63,7 +66,7 @@ const TasksTable = ({
 
 // Table is in memory, pagination and sorting are disabled.
 // TODO: Make a front-end only unconnected table component with client side 
ordering and pagination
-const ActionAccordion = ({ affectedTasks, groupByRunId = false, note, setNote 
}: Props) => {
+const ActionAccordion = ({ affectedTasks, groupByRunId = false, note, 
selection, setNote }: Props) => {
   const showTaskSection = affectedTasks !== undefined;
   const { t: translate } = useTranslation();
 
@@ -121,6 +124,7 @@ const ActionAccordion = ({ affectedTasks, groupByRunId = 
false, note, setNote }:
                       <Accordion.ItemContent>
                         <TasksTable
                           
noRowsMessage={translate("dags:runAndTaskActions.affectedTasks.noItemsFound")}
+                          selection={selection}
                           tasks={tis}
                         />
                       </Accordion.ItemContent>
@@ -130,6 +134,7 @@ const ActionAccordion = ({ affectedTasks, groupByRunId = 
false, note, setNote }:
               ) : (
                 <TasksTable
                   
noRowsMessage={translate("dags:runAndTaskActions.affectedTasks.noItemsFound")}
+                  selection={selection}
                   tasks={affectedTasks.task_instances}
                 />
               )}
diff --git 
a/airflow-core/src/airflow/ui/src/components/ActionAccordion/columns.tsx 
b/airflow-core/src/airflow/ui/src/components/ActionAccordion/columns.tsx
index cda92ab45f3..141e29a44da 100644
--- a/airflow-core/src/airflow/ui/src/components/ActionAccordion/columns.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ActionAccordion/columns.tsx
@@ -16,13 +16,72 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { Table } from "@tanstack/react-table";
 import type { TFunction } from "i18next";
 
 import type { TaskInstanceResponse } from "openapi/requests/types.gen";
 import type { MetaColumn } from "src/components/DataTable/types";
 import { StateBadge } from "src/components/StateBadge";
+import { Checkbox } from "src/components/ui/Checkbox";
 
-export const getColumns = (translate: TFunction): 
Array<MetaColumn<TaskInstanceResponse>> => [
+// Stable per-row key; dag_run_id keeps the same task distinct across runs 
(past/future
+// expansion), and map_index disambiguates the mapped instances of one task.
+export const taskInstanceKey = (ti: TaskInstanceResponse): string =>
+  `${ti.dag_run_id}:${ti.task_id}:${ti.map_index}`;
+
+// When provided, rows render a leading checkbox so the user can exclude 
individual
+// task instances from the action. Excluded keys are tracked by the caller.
+export type RowSelection = {
+  excludedKeys: Set<string>;
+  onToggle: (key: string, included: boolean) => void;
+};
+
+// Header "select all" checkbox that toggles every task instance in its table 
at once.
+const renderSelectAllHeader = (selection: RowSelection, table: 
Table<TaskInstanceResponse>) => {
+  const keys = table.getRowModel().rows.map((row) => 
taskInstanceKey(row.original));
+  const excludedCount = keys.filter((key) => 
selection.excludedKeys.has(key)).length;
+  const allExcluded = keys.length > 0 && excludedCount === keys.length;
+  const someExcluded = excludedCount > 0 && excludedCount < keys.length;
+
+  return (
+    <Checkbox
+      borderWidth={1}
+      checked={someExcluded ? "indeterminate" : !allExcluded}
+      onCheckedChange={(event) => {
+        const included = Boolean(event.checked);
+
+        keys.forEach((key) => selection.onToggle(key, included));
+      }}
+    />
+  );
+};
+
+export const getColumns = (
+  translate: TFunction,
+  selection?: RowSelection,
+): Array<MetaColumn<TaskInstanceResponse>> => [
+  ...(selection
+    ? [
+        {
+          accessorKey: "select",
+          cell: ({ row: { original } }: { row: { original: 
TaskInstanceResponse } }) => {
+            const key = taskInstanceKey(original);
+
+            return (
+              <Checkbox
+                borderWidth={1}
+                checked={!selection.excludedKeys.has(key)}
+                onCheckedChange={(event) => selection.onToggle(key, 
Boolean(event.checked))}
+              />
+            );
+          },
+          enableSorting: false,
+          header: ({ table }: { table: Table<TaskInstanceResponse> }) =>
+            renderSelectAllHeader(selection, table),
+          meta: { skeletonWidth: 10 },
+        } satisfies MetaColumn<TaskInstanceResponse>,
+      ]
+    : []),
   {
     accessorKey: "task_id",
     header: translate("taskId"),
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 9cb0f5c644a..23e16ae28d7 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
@@ -21,6 +21,7 @@ import { useEffect, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { GoAlertFill } from "react-icons/go";
 
+import type { ClearTaskInstancesBody } from "openapi/requests/types.gen";
 import { Dialog } from "src/components/ui";
 import { useClearTaskInstancesDryRun } from 
"src/queries/useClearTaskInstancesDryRun";
 import { getRelativeTime } from "src/utils/datetimeUtils";
@@ -35,6 +36,7 @@ type Props = {
     onlyFailed?: boolean;
     past?: boolean;
     taskId: string;
+    taskIds?: ClearTaskInstancesBody["task_ids"];
     upstream?: boolean;
   };
   readonly onClose: () => void;
@@ -51,6 +53,7 @@ const ClearTaskInstanceConfirmationDialog = ({
   preventRunningTask,
 }: Props) => {
   const { t: translate } = useTranslation();
+  const useExplicitTaskIds = dagDetails?.taskIds !== undefined;
   const { data, isFetching } = useClearTaskInstancesDryRun({
     dagId: dagDetails?.dagId ?? "",
     options: {
@@ -62,13 +65,14 @@ const ClearTaskInstanceConfirmationDialog = ({
     },
     requestBody: {
       dag_run_id: dagDetails?.dagRunId ?? "",
-      include_downstream: dagDetails?.downstream,
+      include_downstream: useExplicitTaskIds ? false : dagDetails?.downstream,
       include_future: dagDetails?.future,
       include_past: dagDetails?.past,
-      include_upstream: dagDetails?.upstream,
+      include_upstream: useExplicitTaskIds ? false : dagDetails?.upstream,
       only_failed: dagDetails?.onlyFailed,
-      task_ids:
-        dagDetails?.mapIndex === undefined
+      task_ids: useExplicitTaskIds
+        ? dagDetails.taskIds
+        : 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 02f20f2a9b7..0026da07395 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
@@ -17,13 +17,14 @@
  * under the License.
  */
 import { Button, Flex, Heading, useDisclosure, VStack } from 
"@chakra-ui/react";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
 import { useTranslation } from "react-i18next";
 import { CgRedo } from "react-icons/cg";
 
 import { useDagServiceGetDagDetails } from "openapi/queries";
-import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import type { ClearTaskInstancesBody, TaskInstanceResponse } from 
"openapi/requests/types.gen";
 import { ActionAccordion } from "src/components/ActionAccordion";
+import { taskInstanceKey } from "src/components/ActionAccordion/columns";
 import { useRerunWithLatestVersion } from 
"src/components/Clear/useRerunWithLatestVersion";
 import Time from "src/components/Time";
 import { Checkbox, Dialog } from "src/components/ui";
@@ -152,6 +153,37 @@ const ClearTaskInstanceDialog = (props: Props) => {
     total_entries: 0,
   };
 
+  // Tasks the user has unticked in the affected list; excluded from the clear.
+  const [excludedKeys, setExcludedKeys] = useState<Set<string>>(new Set());
+
+  const toggleTask = (key: string, included: boolean) =>
+    setExcludedKeys((prev) => {
+      const next = new Set(prev);
+
+      if (included) {
+        next.delete(key);
+      } else {
+        next.add(key);
+      }
+
+      return next;
+    });
+
+  // The dry run already resolved the full affected set, so on confirm we send 
those
+  // task instances explicitly (minus the unticked ones) with the 
graph-expansion flags
+  // off, instead of re-deriving them from the selected task + 
upstream/downstream.
+  const keptTaskInstances = useMemo(
+    () => affectedTasks.task_instances.filter((ti) => 
!excludedKeys.has(taskInstanceKey(ti))),
+    [affectedTasks.task_instances, excludedKeys],
+  );
+
+  const checkedTaskIds = useMemo<ClearTaskInstancesBody["task_ids"]>(
+    () => keptTaskInstances.map((ti) => (ti.map_index < 0 ? ti.task_id : 
[ti.task_id, ti.map_index])),
+    [keptTaskInstances],
+  );
+
+  const hasExclusions = excludedKeys.size > 0;
+
   return (
     <>
       <Dialog.Root lazyMount onOpenChange={onCloseDialog} open={openDialog ? 
!open : false}>
@@ -212,7 +244,12 @@ const ClearTaskInstanceDialog = (props: Props) => {
                 ]}
               />
             </Flex>
-            <ActionAccordion affectedTasks={affectedTasks} note={note} 
setNote={setNote} />
+            <ActionAccordion
+              affectedTasks={affectedTasks}
+              note={note}
+              selection={{ excludedKeys, onToggle: toggleTask }}
+              setNote={setNote}
+            />
             <Flex
               {...(shouldShowRunOnLatestOption ? { alignItems: "center" } : 
{})}
               gap={3}
@@ -234,50 +271,95 @@ const ClearTaskInstanceDialog = (props: Props) => {
               >
                 
{translate("dags:runAndTaskActions.options.preventRunningTasks")}
               </Checkbox>
-              <Button disabled={affectedTasks.total_entries === 0} 
loading={isPending} onClick={onOpen}>
+              <Button
+                disabled={affectedTasks.total_entries === 0 || 
checkedTaskIds?.length === 0}
+                loading={isPending}
+                onClick={onOpen}
+              >
                 <CgRedo /> {translate("modal.confirm")}
               </Button>
             </Flex>
           </Dialog.Body>
         </Dialog.Content>
       </Dialog.Root>
-      <ClearTaskInstanceConfirmationDialog
-        dagDetails={{
-          dagId,
-          dagRunId,
-          downstream,
-          future,
-          mapIndex,
-          onlyFailed,
-          past,
-          taskId,
-          upstream,
-        }}
-        onClose={onClose}
-        onConfirm={() => {
-          const noteChanged = note !== (taskInstance?.note ?? null);
-
-          mutate({
+      {open ? (
+        <ClearTaskInstanceConfirmationDialog
+          dagDetails={{
             dagId,
-            requestBody: {
-              dag_run_id: dagRunId,
-              dry_run: false,
-              include_downstream: downstream,
-              include_future: future,
-              include_past: past,
-              include_upstream: upstream,
-              note: noteChanged ? note : undefined,
-              only_failed: onlyFailed,
-              run_on_latest_version: runOnLatestVersion,
-              task_ids: allMapped ? [taskId] : [[taskId, mapIndex as number]],
-              ...(preventRunningTask ? { prevent_running_task: true } : {}),
-            },
-          });
-          onCloseDialog();
-        }}
-        open={open}
-        preventRunningTask={preventRunningTask}
-      />
+            dagRunId,
+            downstream,
+            future,
+            mapIndex,
+            onlyFailed,
+            past,
+            taskId,
+            ...(hasExclusions ? { taskIds: checkedTaskIds } : {}),
+            upstream,
+          }}
+          onClose={onClose}
+          onConfirm={() => {
+            const noteChanged = note !== (taskInstance?.note ?? null);
+
+            if (hasExclusions) {
+              // The affected set is already resolved across (potentially) 
multiple runs.
+              // The clear endpoint only targets one run per request, so group 
the kept
+              // instances by run and fire one run-scoped clear each, with the 
graph-expansion
+              // flags off. This honors per-run exclusions (e.g. keep task X 
in run 1 but drop
+              // it from run 2) that a single flat request cannot express.
+              const idsByRun = new Map<string, 
NonNullable<ClearTaskInstancesBody["task_ids"]>>();
+
+              for (const ti of keptTaskInstances) {
+                const ids = idsByRun.get(ti.dag_run_id) ?? [];
+
+                ids.push(ti.map_index < 0 ? ti.task_id : [ti.task_id, 
ti.map_index]);
+                idsByRun.set(ti.dag_run_id, ids);
+              }
+
+              for (const [runId, taskIds] of idsByRun) {
+                mutate({
+                  dagId,
+                  requestBody: {
+                    dag_run_id: runId,
+                    dry_run: false,
+                    include_downstream: false,
+                    include_future: false,
+                    include_past: false,
+                    include_upstream: false,
+                    note: noteChanged ? note : undefined,
+                    only_failed: onlyFailed,
+                    run_on_latest_version: runOnLatestVersion,
+                    task_ids: taskIds,
+                    ...(preventRunningTask ? { prevent_running_task: true } : 
{}),
+                  },
+                });
+              }
+              onCloseDialog();
+
+              return;
+            }
+
+            mutate({
+              dagId,
+              requestBody: {
+                dag_run_id: dagRunId,
+                dry_run: false,
+                include_downstream: downstream,
+                include_future: future,
+                include_past: past,
+                include_upstream: upstream,
+                note: noteChanged ? note : undefined,
+                only_failed: onlyFailed,
+                run_on_latest_version: runOnLatestVersion,
+                task_ids: allMapped ? [taskId] : [[taskId, mapIndex as 
number]],
+                ...(preventRunningTask ? { prevent_running_task: true } : {}),
+              },
+            });
+            onCloseDialog();
+          }}
+          open={open}
+          preventRunningTask={preventRunningTask}
+        />
+      ) : null}
     </>
   );
 };

Reply via email to