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 e609f032f40 UI: Bulk mark Dag runs as success/failed from multi-select
(#68278)
e609f032f40 is described below
commit e609f032f408ab1673bb0ce80eef99b474307c8c
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Jun 11 18:46:38 2026 +0200
UI: Bulk mark Dag runs as success/failed from multi-select (#68278)
Add a bulk Mark-as-success/failed action to the Dag Runs multi-select
action bar, next to the existing bulk Clear and Delete. Marking many runs
at once goes through the bulk Dag run endpoint in a single request instead
of one call per run.
---
.../src/pages/DagRuns/BulkMarkDagRunsAsButton.tsx | 130 +++++++++++++++++++++
.../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx | 2 +
.../airflow/ui/src/queries/useBulkPatchDagRun.ts | 115 ++++++++++++++++++
3 files changed, 247 insertions(+)
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkMarkDagRunsAsButton.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkMarkDagRunsAsButton.tsx
new file mode 100644
index 00000000000..cb08961d80a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/BulkMarkDagRunsAsButton.tsx
@@ -0,0 +1,130 @@
+/*!
+ * 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 { Badge, Box, Button, Flex, Heading, HStack, VStack, useDisclosure }
from "@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiX } from "react-icons/fi";
+import { LuCheck } from "react-icons/lu";
+
+import type { DagRunMutableStates, DAGRunResponse } from
"openapi/requests/types.gen";
+import { ActionAccordion } from "src/components/ActionAccordion";
+import { ActionErrors } from "src/components/ActionErrors";
+import { allowedStates } from "src/components/MarkAs/utils";
+import { StateBadge } from "src/components/StateBadge";
+import { Dialog, Menu } from "src/components/ui";
+import { useBulkPatchDagRun } from "src/queries/useBulkPatchDagRun";
+
+type Props = {
+ readonly deselectKeys: (keys: Array<string>) => void;
+ readonly selectedDagRuns: Array<DAGRunResponse>;
+};
+
+const BulkMarkDagRunsAsButton = ({ deselectKeys, selectedDagRuns }: Props) => {
+ const { t: translate } = useTranslation(["common", "dags"]);
+ const { onClose, onOpen, open } = useDisclosure();
+ const [state, setState] = useState<DagRunMutableStates>("success");
+ const [note, setNote] = useState<string | null>(null);
+ const { bulkAction, data, error, isPending, reset } = useBulkPatchDagRun({
+ deselectKeys,
+ onSuccessConfirm: onClose,
+ });
+
+ const handleOpen = (newState: DagRunMutableStates) => {
+ setState(newState);
+ setNote(null);
+ reset();
+ onOpen();
+ };
+
+ return (
+ <Box>
+ <Menu.Root positioning={{ gutter: 0, placement: "top" }}>
+ <Menu.Trigger asChild>
+ <Button variant="outline">
+ <HStack gap={1} mx={1}>
+ <LuCheck />
+ <span>/</span>
+ <FiX />
+ </HStack>
+ {translate("dags:runAndTaskActions.markAs.button", { type:
translate("dagRun_other") })}
+ </Button>
+ </Menu.Trigger>
+ <Menu.Content>
+ {allowedStates.map((menuState) => (
+ <Menu.Item key={menuState} onClick={() => handleOpen(menuState)}
value={menuState}>
+ <HStack justify="space-between" width="full">
+ <StateBadge
state={menuState}>{translate(`common:states.${menuState}`)}</StateBadge>
+ <Badge colorPalette="gray" variant="subtle">
+ {selectedDagRuns.length}
+ </Badge>
+ </HStack>
+ </Menu.Item>
+ ))}
+ </Menu.Content>
+ </Menu.Root>
+
+ <Dialog.Root onOpenChange={onClose} open={open}>
+ <Dialog.Content backdrop>
+ <Dialog.Header>
+ <VStack align="start" gap={4}>
+ <Heading size="xl">
+ {translate("dags:runAndTaskActions.markAs.title", {
+ state,
+ type: translate("dagRun_other"),
+ })}{" "}
+ <StateBadge state={state} />
+ </Heading>
+ </VStack>
+ </Dialog.Header>
+
+ <Dialog.CloseTrigger />
+ <Dialog.Body width="full">
+ <ActionAccordion note={note} setNote={setNote} />
+ <ActionErrors actionResponse={data?.update} error={error} />
+ <Flex justifyContent="end" mt={3}>
+ <Button
+ loading={isPending}
+ onClick={() => {
+ bulkAction({
+ actions: [
+ {
+ action: "update" as const,
+ action_on_non_existence: "skip",
+ entities: selectedDagRuns.map((dagRun) => ({
+ dag_id: dagRun.dag_id,
+ dag_run_id: dagRun.dag_run_id,
+ note,
+ state,
+ })),
+ },
+ ],
+ });
+ }}
+ >
+ {translate("modal.confirm")}
+ </Button>
+ </Flex>
+ </Dialog.Body>
+ </Dialog.Content>
+ </Dialog.Root>
+ </Box>
+ );
+};
+
+export default BulkMarkDagRunsAsButton;
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index 3e598189a87..4443ec53056 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -51,6 +51,7 @@ import { renderDuration, useAutoRefresh, isStatePending }
from "src/utils";
import BulkClearDagRunsButton from "./BulkClearDagRunsButton";
import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton";
+import BulkMarkDagRunsAsButton from "./BulkMarkDagRunsAsButton";
import { DagRunsFilters } from "./DagRunsFilters";
import DeleteRunButton from "./DeleteRunButton";
@@ -360,6 +361,7 @@ export const DagRuns = () => {
</ActionBar.SelectionTrigger>
<ActionBar.Separator />
<BulkClearDagRunsButton deselectKeys={deselectKeys}
selectedDagRuns={selectedDagRuns} />
+ <BulkMarkDagRunsAsButton deselectKeys={deselectKeys}
selectedDagRuns={selectedDagRuns} />
<BulkDeleteDagRunsButton deselectKeys={deselectKeys}
selectedDagRuns={selectedDagRuns} />
<ActionBar.CloseTrigger onClick={clearSelections} />
</ActionBar.Content>
diff --git a/airflow-core/src/airflow/ui/src/queries/useBulkPatchDagRun.ts
b/airflow-core/src/airflow/ui/src/queries/useBulkPatchDagRun.ts
new file mode 100644
index 00000000000..06fae1f2b1f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useBulkPatchDagRun.ts
@@ -0,0 +1,115 @@
+/*!
+ * 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 { useRef } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+ useDagRunServiceBulkDagRuns,
+ useDagRunServiceGetDagRunKey,
+ useDagRunServiceGetDagRunsKey,
+ useGanttServiceGetGanttDataKey,
+ useTaskInstanceServiceGetMappedTaskInstanceKey,
+ useTaskInstanceServiceGetTaskInstanceKey,
+ useTaskInstanceServiceGetTaskInstancesKey,
+} from "openapi/queries";
+import type { BulkBody_BulkDAGRunBody_, BulkResponse } from
"openapi/requests/types.gen";
+import { toaster } from "src/components/ui";
+
+import { gridQueryKeys, tiPerAttemptQueryKeys } from "./gridViewQueryKeys";
+import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun";
+
+type Props = {
+ readonly deselectKeys: (keys: Array<string>) => void;
+ readonly onSuccessConfirm: VoidFunction;
+};
+
+export const useBulkPatchDagRun = ({ deselectKeys, onSuccessConfirm }: Props)
=> {
+ const queryClient = useQueryClient();
+ const affectedDagIds = useRef<Set<string>>(new Set());
+ const { t: translate } = useTranslation(["common", "dags"]);
+
+ const onSuccess = async (responseData: BulkResponse) => {
+ // Marking runs changes run + task instance states, so invalidate the same
surfaces the
+ // single-run mark (usePatchDagRun) does: run/TI lists, run +
single/mapped TI details, gantt,
+ // grid, and the clear dry-run preview.
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey:
[useDagRunServiceGetDagRunsKey] }),
+ queryClient.invalidateQueries({ queryKey: [useDagRunServiceGetDagRunKey]
}),
+ queryClient.invalidateQueries({ queryKey:
[useTaskInstanceServiceGetTaskInstancesKey] }),
+ queryClient.invalidateQueries({ queryKey:
[useTaskInstanceServiceGetTaskInstanceKey] }),
+ queryClient.invalidateQueries({ queryKey:
[useTaskInstanceServiceGetMappedTaskInstanceKey] }),
+ queryClient.invalidateQueries({ queryKey:
[useGanttServiceGetGanttDataKey] }),
+ ...tiPerAttemptQueryKeys.map((key) => queryClient.invalidateQueries({
queryKey: key })),
+ ...[...affectedDagIds.current].flatMap((dagId) => [
+ ...gridQueryKeys(dagId).map((key) => queryClient.invalidateQueries({
queryKey: key })),
+ queryClient.invalidateQueries({ queryKey: [useClearDagRunDryRunKey,
dagId] }),
+ ]),
+ ]);
+
+ const updateResult = responseData.update;
+
+ if (!updateResult) {
+ return;
+ }
+
+ const successKeys = updateResult.success ?? [];
+ const actionErrors = updateResult.errors ?? [];
+
+ if (successKeys.length > 0) {
+ toaster.create({
+ description: translate("toaster.bulkUpdate.success.description", {
+ count: successKeys.length,
+ keys: successKeys.join(", "),
+ resourceName: translate("dagRun_other"),
+ }),
+ title: translate("toaster.bulkUpdate.success.title", {
+ resourceName: translate("dagRun_other"),
+ }),
+ type: "success",
+ });
+ deselectKeys(successKeys);
+ }
+
+ // Per-entity failures (status 200 with items in ``errors``) keep the
dialog open
+ // so the user can see what failed; the consumer renders
``data.update.errors``.
+ if (actionErrors.length === 0) {
+ onSuccessConfirm();
+ }
+ };
+
+ const { data, error, isPending, mutate, reset } =
useDagRunServiceBulkDagRuns({ onSuccess });
+
+ const bulkAction = (requestBody: BulkBody_BulkDAGRunBody_) => {
+ reset();
+ const dagIds = new Set<string>();
+
+ for (const action of requestBody.actions) {
+ for (const entity of action.entities) {
+ if (typeof entity !== "string" && entity.dag_id !== null &&
entity.dag_id !== undefined) {
+ dagIds.add(entity.dag_id);
+ }
+ }
+ }
+ affectedDagIds.current = dagIds;
+ mutate({ dagId: "~", requestBody });
+ };
+
+ return { bulkAction, data, error, isPending, reset };
+};