diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 9fd0cc4efe940..eef5b4cd09df2 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -25,6 +25,17 @@ "requiredActions": "Required Actions", "xcoms": "XComs" }, + "bulkAction": { + "delete": { + "taskInstance": { + "success": { + "description": "{{count}} task instances deleted successfully. Keys: {{keys}}", + "title": "Delete Task Instances Request Successful" + }, + "warning": "This will remove all metadata related to the Task Instances, including task instance history and audit record." + } + } + }, "collapseAllExtra": "Collapse all extra json", "collapseDetailsPanel": "Collapse Details Panel", "createdAssetEvent_one": "Created Asset Event", @@ -74,6 +85,7 @@ "dagWarnings": "Dag warnings/errors", "defaultToGraphView": "Default to graph view", "defaultToGridView": "Default to grid view", + "delete": "Delete", "direction": "Direction", "docs": { "documentation": "Documentation", @@ -183,6 +195,7 @@ "users": "Users" }, "selectLanguage": "Select Language", + "selected": "Selected", "showDetailsPanel": "Show Details Panel", "signedInAs": "Signed in as", "source": { diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json index d54d3b6ba5a88..4246cbd6d2b91 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/common.json @@ -25,6 +25,17 @@ "requiredActions": "待回應的任務實例", "xcoms": "XComs" }, + "bulkAction": { + "delete": { + "taskInstance": { + "success": { + "description": "已成功刪除 {{count}} 個任務實例。鍵:{{keys}}", + "title": "已提交刪除任務實例請求" + }, + "warning": "這將刪除所有與任務實例相關的系統資料,包括歷史記錄和審計日誌。" + } + } + }, "collapseDetailsPanel": "收起詳細資訊", "createdAssetEvent_one": "已建立資源事件", "createdAssetEvent_other": "已建立資源事件", @@ -73,6 +84,7 @@ "dagWarnings": "Dag 警告 / 錯誤", "defaultToGraphView": "預設使用圖形視圖", "defaultToGridView": "預設使用網格視圖", + "delete": "刪除", "direction": "書寫方向", "docs": { "documentation": "文件", 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 a08b9744b51b0..a84f6771b6b0c 100644 --- a/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx +++ b/airflow-core/src/airflow/ui/src/components/ActionAccordion/ActionAccordion.tsx @@ -30,8 +30,8 @@ import { getColumns } from "./columns"; type Props = { readonly affectedTasks?: TaskInstanceCollectionResponse; - readonly note: DAGRunResponse["note"]; - readonly setNote: (value: string) => void; + readonly note?: DAGRunResponse["note"]; + readonly setNote?: (value: string) => void; }; // Table is in memory, pagination and sorting are disabled. @@ -72,40 +72,42 @@ const ActionAccordion = ({ affectedTasks, note, setNote }: Props) => { ) : undefined} - - - {translate("note.label")} - - - ) => setNote(event.target.value)} - value={note ?? ""} - > - + + {translate("note.label")} + + + ) => setNote(event.target.value)} + value={note ?? ""} > - {Boolean(note) ? ( - {note} - ) : ( - {translate("note.placeholder")} - )} - - - - - + + {Boolean(note) ? ( + {note} + ) : ( + {translate("note.placeholder")} + )} + + + + + + ) : undefined} ); }; diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsButton.tsx b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsButton.tsx new file mode 100644 index 0000000000000..3f10d219fd96a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsButton.tsx @@ -0,0 +1,106 @@ +/*! + * 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 { useDisclosure } from "@chakra-ui/react"; +import type { TaskInstanceResponse, TaskInstanceState } from "openapi-gen/requests/types.gen"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MdArrowDropDown } from "react-icons/md"; + +import { StateBadge } from "src/components/StateBadge"; +import { Menu, Tooltip } from "src/components/ui"; +import ActionButton from "src/components/ui/ActionButton"; + +import PatchTaskInstancesDialog from "./MarkTaskInstancesAsDialog"; + +type Props = { + readonly clearSelections: () => void; + readonly dagId: string; + readonly dagRunId: string; + readonly patchKeys: Array; +}; + +const MarkTaskInstancesAsButton = ({ clearSelections, dagId, dagRunId, patchKeys }: Props) => { + const { onClose, onOpen, open } = useDisclosure(); + const [selectedState, setSelectedState] = useState("success"); + + const allowedStates: Array = ["success", "failed"]; + + const { t: translate } = useTranslation(); + + if (patchKeys.length === 0) { + return undefined; + } + + const type = translate("common:taskInstance_other"); + const patchButtonText = translate("dags:runAndTaskActions.markAs.button", { type }); + + return ( + <> + + + } + text={patchButtonText} + variant="outline" + withText + /> + + + {allowedStates.map((menuState) => { + const content = translate( + `dags:runAndTaskActions.markAs.buttonTooltip.${menuState === "success" ? "success" : "failed"}`, + ); + + return ( + + { + setSelectedState(menuState); + onOpen(); + }} + value={menuState} + > + + {translate(`common:states.${menuState}`)} + + + + ); + })} + + + + + ); +}; + +export default MarkTaskInstancesAsButton; diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsDialog.tsx b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsDialog.tsx new file mode 100644 index 0000000000000..f1b123ce92e27 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstances/MarkTaskInstancesAsDialog.tsx @@ -0,0 +1,109 @@ +/*! + * 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 { Button, Flex, Heading, VStack } from "@chakra-ui/react"; +import type { + TaskInstanceCollectionResponse, + TaskInstanceResponse, + TaskInstanceState, +} from "openapi-gen/requests/types.gen"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { ActionAccordion } from "src/components/ActionAccordion"; +import { StateBadge } from "src/components/StateBadge"; +import { Dialog } from "src/components/ui/Dialog"; +import { useBulkPatchTaskInstances } from "src/queries/useBulkPatchTaskInstances"; + +type Props = { + readonly clearSelections: () => void; + readonly dagId: string; + readonly dagRunId: string; + readonly onClose: () => void; + readonly open: boolean; + readonly patchKeys: Array; + readonly selectedState: TaskInstanceState; +}; + +const MarkTaskInstancesAsDialog = ({ + clearSelections, + dagId, + dagRunId, + onClose, + open, + patchKeys, + selectedState, +}: Props) => { + const [note, setNote] = useState(); + + const { isPending, patchTaskInstances } = useBulkPatchTaskInstances({ + dagId, + dagRunId, + onSuccessConfirm: () => { + clearSelections(); + onClose(); + }, + }); + const { t: translate } = useTranslation(); + + const affectedTasks = { + task_instances: patchKeys, + total_entries: patchKeys.length, + } as TaskInstanceCollectionResponse; + + const handlePatch = (state: TaskInstanceState) => { + const actionValue = state === "failed" ? "set_failed" : "set_success"; + + patchTaskInstances(patchKeys, actionValue, { note }); + onClose(); + }; + + return ( + + + + + + + {translate("dags:runAndTaskActions.markAs.title", { + state: selectedState, + type: translate("common:taskInstance_other"), + })} + : + {" "} + + + + + + + + + + + + + + + + ); +}; + +export default MarkTaskInstancesAsDialog; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstances/DeleteTaskInstancesButton.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstances/DeleteTaskInstancesButton.tsx new file mode 100644 index 0000000000000..5bf4e987eab34 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/DeleteTaskInstancesButton.tsx @@ -0,0 +1,99 @@ +/*! + * 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 { useDisclosure } from "@chakra-ui/react"; +import { Button, Flex, Heading, Text } from "@chakra-ui/react"; +import type { TaskInstanceCollectionResponse, TaskInstanceResponse } from "openapi-gen/requests/types.gen"; +import { useTranslation } from "react-i18next"; +import { FiTrash2 } from "react-icons/fi"; + +import { ActionAccordion } from "src/components/ActionAccordion"; +import ActionButton from "src/components/ui/ActionButton"; +import { Dialog } from "src/components/ui/Dialog"; +import { useBulkDeleteTaskInstances } from "src/queries/useBulkDeleteTaskInstances"; + +type Props = { + readonly clearSelections: () => void; + readonly dagId: string; + readonly dagRunId: string; + readonly deleteKeys: Array; +}; + +const DeleteTaskInstancesButton = ({ clearSelections, dagId, dagRunId, deleteKeys }: Props) => { + const { onClose, onOpen, open } = useDisclosure(); + const { deleteTaskInstances, isPending } = useBulkDeleteTaskInstances({ + dagId, + dagRunId, + onSuccessConfirm: () => { + clearSelections(); + onClose(); + }, + }); + const { t: translate } = useTranslation(); + + if (deleteKeys.length === 0) { + return undefined; + } + + const type = translate("common:taskInstance_other"); + const title = translate("dags:runAndTaskActions.delete.dialog.title", { type }); + const warningText = translate("common:bulkAction.delete.taskInstance.warning", { type }); + const deleteButtonText = translate("dags:runAndTaskActions.delete.button", { type }); + + const affectedTasks = { + task_instances: deleteKeys, + total_entries: deleteKeys.length, + } as TaskInstanceCollectionResponse; + + return ( + <> + } + onClick={onOpen} + text={deleteButtonText} + variant="outline" + withText + /> + + + + + {title} + + {warningText} + + + + + + + + + + + ); +}; + +export default DeleteTaskInstancesButton; diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx index f4e0e14aca6c5..33024755e32b8 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx @@ -30,19 +30,27 @@ import type { TaskInstanceResponse } from "openapi/requests/types.gen"; import { ClearTaskInstanceButton } from "src/components/Clear"; import { DagVersion } from "src/components/DagVersion"; import { DataTable } from "src/components/DataTable"; +import { type GetColumnsParams, useRowSelection } from "src/components/DataTable/useRowSelection"; import { useTableURLState } from "src/components/DataTable/useTableUrlState"; import { ErrorAlert } from "src/components/ErrorAlert"; import { MarkTaskInstanceAsButton } from "src/components/MarkAs"; +import MarkTaskInstancesAsButton from "src/components/MarkAs/TaskInstances/MarkTaskInstancesAsButton"; import { StateBadge } from "src/components/StateBadge"; import Time from "src/components/Time"; import { TruncatedText } from "src/components/TruncatedText"; +import { ActionBar } from "src/components/ui/ActionBar"; +import { Checkbox } from "src/components/ui/Checkbox"; +import { Tooltip } from "src/components/ui/Tooltip"; import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searchParams"; import { useAutoRefresh, isStatePending, renderDuration } from "src/utils"; import { getTaskInstanceLink } from "src/utils/links"; import DeleteTaskInstanceButton from "./DeleteTaskInstanceButton"; +import DeleteTaskInstancesButton from "./DeleteTaskInstancesButton"; import { TaskInstancesFilter } from "./TaskInstancesFilter"; +const SEPARATOR = "SEPARATOR"; + type TaskInstanceRow = { row: { original: TaskInstanceResponse } }; const { @@ -64,9 +72,13 @@ const { TRY_NUMBER: TRY_NUMBER_PARAM, }: SearchParamsKeysType = SearchParamsKeys; -const taskInstanceColumns = ({ +const getColumns = ({ + allRowsSelected, dagId, + onRowSelect, + onSelectAll, runId, + selectedRows, taskId, translate, }: { @@ -74,7 +86,37 @@ const taskInstanceColumns = ({ runId?: string; taskId?: string; translate: TFunction; -}): Array> => [ +} & GetColumnsParams): Array> => [ + { + accessorKey: "select", + cell: ({ row }: TaskInstanceRow) => ( + + onRowSelect( + `${row.original.dag_run_id}${SEPARATOR}${row.original.task_id}${SEPARATOR}${row.original.map_index}`, + Boolean(event.checked), + ) + } + /> + ), + enableSorting: false, + header: () => ( + onSelectAll(Boolean(event.checked))} + /> + ), + meta: { + skeletonWidth: 10, + }, + }, ...(Boolean(dagId) ? [] : [ @@ -291,15 +333,36 @@ export const TaskInstances = () => { }, ); + const { allRowsSelected, clearSelections, handleRowSelect, handleSelectAll, selectedRows } = + useRowSelection({ + data: data?.task_instances, + getKey: (taskInstance) => + `${taskInstance.dag_run_id}${SEPARATOR}${taskInstance.task_id}${SEPARATOR}${taskInstance.map_index}`, + }); + const columns = useMemo( () => - taskInstanceColumns({ + getColumns({ + allRowsSelected, dagId, + onRowSelect: handleRowSelect, + onSelectAll: handleSelectAll, runId, + selectedRows, taskId: Boolean(groupId) ? undefined : taskId, translate, }), - [dagId, runId, groupId, taskId, translate], + [ + allRowsSelected, + dagId, + groupId, + handleRowSelect, + handleSelectAll, + runId, + selectedRows, + taskId, + translate, + ], ); return ( @@ -315,6 +378,63 @@ export const TaskInstances = () => { onStateChange={setTableURLState} total={data?.total_entries} /> + + + + {selectedRows.size} {translate("common:selected")} + + + + { + const [dagRunId, currentTaskId, mapIndex] = id.split(SEPARATOR); + + return data?.task_instances.find( + (ti) => + ti.dag_run_id === dagRunId && + ti.task_id === currentTaskId && + ti.map_index === Number(mapIndex ?? -1), + ); + }) + .filter(Boolean) as Array + } + /> + + + { + const [dagRunId, currentTaskId, mapIndex] = id.split(SEPARATOR); + + return data?.task_instances.find( + (ti) => + ti.dag_run_id === dagRunId && + ti.task_id === currentTaskId && + ti.map_index === Number(mapIndex ?? -1), + ); + }) + .filter(Boolean) as Array + } + /> + + + + ); }; diff --git a/airflow-core/src/airflow/ui/src/queries/useBulkDeleteTaskInstances.ts b/airflow-core/src/airflow/ui/src/queries/useBulkDeleteTaskInstances.ts new file mode 100644 index 0000000000000..1f1cd9a4f82af --- /dev/null +++ b/airflow-core/src/airflow/ui/src/queries/useBulkDeleteTaskInstances.ts @@ -0,0 +1,110 @@ +/*! + * 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 { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + useTaskInstanceServiceGetTaskInstancesKey, + useTaskInstanceServiceBulkTaskInstances, + UseGridServiceGetGridTiSummariesKeyFn, +} from "openapi/queries"; +import type { TaskInstanceResponse } from "openapi/requests"; +import { toaster } from "src/components/ui"; + +type Props = { + readonly dagId?: string; + readonly dagRunId?: string; + readonly onSuccessConfirm: VoidFunction; +}; + +export const useBulkDeleteTaskInstances = ({ dagId, dagRunId, onSuccessConfirm }: Props) => { + const queryClient = useQueryClient(); + const [error, setError] = useState(undefined); + const { t: translate } = useTranslation("common"); + + const onSuccess = async (responseData: { delete?: { errors: Array; success: Array } }) => { + const queryKeys = [ + [useTaskInstanceServiceGetTaskInstancesKey], + dagId === undefined || dagRunId === undefined + ? [] + : [UseGridServiceGetGridTiSummariesKeyFn({ dagId, runId: dagRunId })], + ]; + + await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ queryKey: key }))); + + if (responseData.delete) { + const { errors, success } = responseData.delete; + + if (Array.isArray(errors) && errors.length > 0) { + const apiError = errors[0] as { error: string }; + + setError({ + body: { detail: apiError.error }, + }); + } else if (Array.isArray(success) && success.length > 0) { + toaster.create({ + description: translate("bulkAction.delete.taskInstance.success.description", { + count: success.length, + keys: success.join(", "), + }), + title: translate("bulkAction.delete.taskInstance.success.title"), + type: "success", + }); + onSuccessConfirm(); + } + } + }; + + const onError = (_error: unknown) => { + setError(_error); + }; + + const { isPending, mutate } = useTaskInstanceServiceBulkTaskInstances({ + onError, + onSuccess, + }); + + const deleteTaskInstances = (entities: Array) => { + setError(undefined); + + const isSingleDagRun = + Boolean(dagId) && Boolean(dagRunId) && dagId !== undefined && dagRunId !== undefined; + + mutate({ + dagId: isSingleDagRun ? dagId : "~", + dagRunId: isSingleDagRun ? dagRunId : "~", + requestBody: { + actions: [ + { + action: "delete", + entities: entities.map((ti) => ({ + dag_id: isSingleDagRun ? undefined : ti.dag_id, + dag_run_id: isSingleDagRun ? undefined : ti.dag_run_id, + map_index: ti.map_index, + task_id: ti.task_id, + })), + }, + ], + }, + }); + }; + + return { deleteTaskInstances, error, isPending }; +}; diff --git a/airflow-core/src/airflow/ui/src/queries/useBulkPatchTaskInstances.ts b/airflow-core/src/airflow/ui/src/queries/useBulkPatchTaskInstances.ts new file mode 100644 index 0000000000000..25843ece60fb9 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/queries/useBulkPatchTaskInstances.ts @@ -0,0 +1,136 @@ +/*! + * 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 { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + useTaskInstanceServiceGetTaskInstancesKey, + useTaskInstanceServiceBulkTaskInstances, + UseGridServiceGetGridTiSummariesKeyFn, +} from "openapi/queries"; +import type { TaskInstanceResponse } from "openapi/requests"; +import { toaster } from "src/components/ui"; + +type Props = { + readonly dagId?: string; + readonly dagRunId?: string; + readonly onSuccessConfirm: VoidFunction; +}; + +export const useBulkPatchTaskInstances = ({ dagId, dagRunId, onSuccessConfirm }: Props) => { + const queryClient = useQueryClient(); + const [error, setError] = useState(undefined); + const { t: translate } = useTranslation("common"); + + const onSuccess = async (responseData: { patch?: { errors: Array; success: Array } }) => { + const queryKeys = [ + [useTaskInstanceServiceGetTaskInstancesKey], + dagId === undefined || dagRunId === undefined + ? [] + : [UseGridServiceGetGridTiSummariesKeyFn({ dagId, runId: dagRunId })], + ]; + + await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ queryKey: key }))); + + if (responseData.patch) { + const { errors, success } = responseData.patch; + + if (Array.isArray(errors) && errors.length > 0) { + const apiError = errors[0] as { error: string }; + + setError({ + body: { detail: apiError.error }, + }); + } else if (Array.isArray(success) && success.length > 0) { + toaster.create({ + description: translate("bulkAction.patch.taskInstance.success.description", { + count: success.length, + keys: success.join(", "), + }), + title: translate("bulkAction.patch.taskInstance.success.title"), + type: "success", + }); + onSuccessConfirm(); + } + } + }; + + const onError = (_error: unknown) => { + setError(_error); + }; + + const getNewState = (action: string) => { + switch (action) { + case "set_failed": + return "failed" as const; + case "set_success": + return "success" as const; + default: + return undefined; + } + }; + + const { isPending, mutate } = useTaskInstanceServiceBulkTaskInstances({ + onError, + onSuccess, + }); + + const patchTaskInstances = ( + entities: Array, + selectedAction: string, + options: { + include_downstream?: boolean; + include_future?: boolean; + include_past?: boolean; + include_upstream?: boolean; + note?: string | null; + } = {}, + ) => { + const newState = getNewState(selectedAction); + const isSingleDagRun = + Boolean(dagId) && Boolean(dagRunId) && dagId !== undefined && dagRunId !== undefined; + + mutate({ + dagId: isSingleDagRun ? dagId : "~", + dagRunId: isSingleDagRun ? dagRunId : "~", + requestBody: { + actions: [ + { + action: "update", + entities: entities.map((ti) => ({ + dag_id: isSingleDagRun ? undefined : ti.dag_id, + dag_run_id: isSingleDagRun ? undefined : ti.dag_run_id, + include_downstream: options.include_downstream, + include_future: options.include_future, + include_past: options.include_past, + include_upstream: options.include_upstream, + map_index: ti.map_index, + new_state: newState, + note: options.note, + task_id: ti.task_id, + })), + }, + ], + }, + }); + }; + + return { error, isPending, patchTaskInstances }; +};