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}
</>
);
};