Skip to content

Commit fc5100d

Browse files
committed
fixup! Show current task executions in the UI
1 parent 0e553fa commit fc5100d

24 files changed

Lines changed: 681 additions & 94 deletions

‎airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,12 @@
7575
"clearLater": "Clear later loop iterations",
7676
"clearNote": "Reason for clearing (optional)",
7777
"clearSelected": "Clear selected executions",
78-
"clearTitle": "Clear selected executions",
7978
"clearWhole": "Clear whole mapped expansions",
79+
"empty": "No executions in this run.",
8080
"iteration": "{{node}} · Iteration {{index}}",
8181
"mapped_one": "{{task}} ({{count}} mapped task on this page)",
8282
"mapped_other": "{{task}} ({{count}} mapped tasks on this page)",
83-
"nextPage": "Next page",
8483
"page": "Showing {{start}}–{{end}} of {{total}} executions",
85-
"previousPage": "Previous page",
8684
"runTasks": "Run tasks",
8785
"select": "Select {{task}}",
8886
"title": "Execution"

‎airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearExecutionDialog.test.tsx‎

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,41 @@ import { afterEach, beforeAll, expect, it, vi } from "vitest";
2222

2323
import { TaskInstanceService } from "openapi/requests";
2424

25+
import {
26+
CLEAR_KEEP_TASK_STATE_KEY,
27+
CLEAR_PREVENT_RUNNING_TASK_KEY,
28+
CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
29+
} from "src/constants/localStorage";
2530
import i18n from "src/i18n/config";
2631
import { Wrapper } from "src/utils/Wrapper";
2732

2833
import dagTranslations from "../../../../public/i18n/locales/en/dag.json";
34+
import dagsTranslations from "../../../../public/i18n/locales/en/dags.json";
2935
import { ClearExecutionDialog } from "./ClearExecutionDialog";
3036

31-
beforeAll(() => i18n.addResourceBundle("en", "dag", dagTranslations, true, true));
32-
afterEach(() => vi.restoreAllMocks());
37+
beforeAll(() => {
38+
i18n.addResourceBundle("en", "dag", dagTranslations, true, true);
39+
i18n.addResourceBundle("en", "dags", dagsTranslations, true, true);
40+
});
41+
afterEach(() => {
42+
vi.restoreAllMocks();
43+
localStorage.clear();
44+
});
45+
46+
const execution = {
47+
id: "selected-uuid",
48+
map_index: -1,
49+
note: "existing note",
50+
region_id: "11111111-1111-1111-1111-111111111111",
51+
region_index: 1,
52+
task_display_name: "work",
53+
task_id: "body.work",
54+
};
55+
56+
const findClearRequest = (clear: { mock: { calls: Array<Array<unknown>> } }) =>
57+
(clear.mock.calls as Array<[{ requestBody: { dry_run?: boolean; note?: string | null } }]>)
58+
.map(([request]) => request)
59+
.find((request) => request.requestBody.dry_run === false);
3360

3461
it("keeps stale UUID scope visible when the clear preview rejects an archived execution", async () => {
3562
const clear = vi
@@ -136,3 +163,118 @@ it("previews and clears UUID seeds with separate whole-expansion and later-loop
136163
}),
137164
);
138165
});
166+
167+
it("sends the saved clear defaults in the dry run and the real request", async () => {
168+
localStorage.setItem(CLEAR_PREVENT_RUNNING_TASK_KEY, JSON.stringify(true));
169+
localStorage.setItem(CLEAR_KEEP_TASK_STATE_KEY, JSON.stringify(true));
170+
localStorage.setItem(CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY, JSON.stringify([]));
171+
const clear = vi
172+
.spyOn(TaskInstanceService, "postClearTaskInstances")
173+
.mockResolvedValue({ task_instances: [], total_entries: 1 });
174+
175+
render(<ClearExecutionDialog dagId="dag" executions={[execution]} onClose={vi.fn()} open runId="run" />, {
176+
wrapper: Wrapper,
177+
});
178+
179+
await waitFor(() =>
180+
expect(clear.mock.lastCall?.[0].requestBody).toMatchObject({
181+
dry_run: true,
182+
include_downstream: false,
183+
keep_task_state: true,
184+
prevent_running_task: true,
185+
}),
186+
);
187+
expect(await screen.findByText("1 execution will be cleared.")).toBeVisible();
188+
expect(screen.getByLabelText("Prevent rerun if task is running")).toBeChecked();
189+
expect(screen.getByLabelText("Keep task state and resume")).toBeChecked();
190+
fireEvent.click(screen.getByLabelText("Keep task state and resume"));
191+
fireEvent.click(screen.getByLabelText("Prevent rerun if task is running"));
192+
await waitFor(() =>
193+
expect(clear.mock.lastCall?.[0].requestBody).toMatchObject({
194+
dry_run: true,
195+
keep_task_state: false,
196+
prevent_running_task: false,
197+
}),
198+
);
199+
await waitFor(() =>
200+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeEnabled(),
201+
);
202+
fireEvent.click(screen.getByRole("button", { name: "Clear selected executions" }));
203+
await waitFor(() =>
204+
expect(findClearRequest(clear)?.requestBody).toMatchObject({
205+
keep_task_state: false,
206+
prevent_running_task: false,
207+
}),
208+
);
209+
});
210+
211+
it("hides the affected count while the dry run is pending", async () => {
212+
vi.spyOn(TaskInstanceService, "postClearTaskInstances").mockReturnValue(
213+
new Promise(() => undefined) as ReturnType<typeof TaskInstanceService.postClearTaskInstances>,
214+
);
215+
216+
render(<ClearExecutionDialog dagId="dag" executions={[execution]} onClose={vi.fn()} open runId="run" />, {
217+
wrapper: Wrapper,
218+
});
219+
220+
expect(await screen.findByLabelText("Clear downstream tasks")).toBeVisible();
221+
expect(screen.queryByText(/executions? will be cleared/u)).not.toBeInTheDocument();
222+
});
223+
224+
it("seeds the note from the execution and only sends it when edited", async () => {
225+
const clear = vi
226+
.spyOn(TaskInstanceService, "postClearTaskInstances")
227+
.mockResolvedValue({ task_instances: [], total_entries: 1 });
228+
229+
render(<ClearExecutionDialog dagId="dag" executions={[execution]} onClose={vi.fn()} open runId="run" />, {
230+
wrapper: Wrapper,
231+
});
232+
233+
const note = await screen.findByLabelText("Reason for clearing (optional)");
234+
235+
expect(note).toHaveValue("existing note");
236+
await waitFor(() =>
237+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeEnabled(),
238+
);
239+
fireEvent.click(screen.getByRole("button", { name: "Clear selected executions" }));
240+
await waitFor(() => expect(findClearRequest(clear)).toBeDefined());
241+
expect(findClearRequest(clear)?.requestBody.note).toBeUndefined();
242+
});
243+
244+
it("does not send an empty note when the user erases the existing one", async () => {
245+
const clear = vi
246+
.spyOn(TaskInstanceService, "postClearTaskInstances")
247+
.mockResolvedValue({ task_instances: [], total_entries: 1 });
248+
249+
render(<ClearExecutionDialog dagId="dag" executions={[execution]} onClose={vi.fn()} open runId="run" />, {
250+
wrapper: Wrapper,
251+
});
252+
253+
fireEvent.change(await screen.findByLabelText("Reason for clearing (optional)"), { target: { value: "" } });
254+
await waitFor(() =>
255+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeEnabled(),
256+
);
257+
fireEvent.click(screen.getByRole("button", { name: "Clear selected executions" }));
258+
await waitFor(() => expect(findClearRequest(clear)).toBeDefined());
259+
expect(findClearRequest(clear)?.requestBody.note).toBeUndefined();
260+
});
261+
262+
it("resets the dialog state once it closes", async () => {
263+
vi.spyOn(TaskInstanceService, "postClearTaskInstances").mockResolvedValue({
264+
task_instances: [],
265+
total_entries: 1,
266+
});
267+
const props = { dagId: "dag", executions: [execution], onClose: vi.fn(), runId: "run" };
268+
const { rerender } = render(<ClearExecutionDialog {...props} open />, { wrapper: Wrapper });
269+
270+
fireEvent.click(await screen.findByLabelText("Clear later loop iterations"));
271+
fireEvent.change(screen.getByLabelText("Reason for clearing (optional)"), { target: { value: "typed" } });
272+
await waitFor(() => expect(screen.getByLabelText("Clear later loop iterations")).not.toBeChecked());
273+
await waitFor(() => expect(screen.getByLabelText("Reason for clearing (optional)")).toHaveValue("typed"));
274+
rerender(<ClearExecutionDialog {...props} open={false} />);
275+
await waitFor(() => expect(screen.queryByLabelText("Clear later loop iterations")).not.toBeInTheDocument());
276+
rerender(<ClearExecutionDialog {...props} open />);
277+
278+
expect(await screen.findByLabelText("Clear later loop iterations")).toBeChecked();
279+
expect(screen.getByLabelText("Reason for clearing (optional)")).toHaveValue("existing note");
280+
});

‎airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearExecutionDialog.tsx‎

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* specific language governing permissions and limitations
1717
* under the License.
1818
*/
19-
import { useState } from "react";
19+
import { useEffect, useState } from "react";
2020

2121
import { Button, Stack, Text, Textarea } from "@chakra-ui/react";
2222
import { useTranslation } from "react-i18next";
@@ -27,12 +27,17 @@ import { Checkbox, Modal } from "src/system-components";
2727

2828
import { ErrorAlert } from "src/components/ErrorAlert";
2929

30+
import {
31+
useClearKeepTaskStateDefault,
32+
useClearPreventRunningTaskDefault,
33+
useClearTaskInstanceDefaultOptions,
34+
} from "src/hooks/useUserSettings";
3035
import { useClearTaskInstances } from "src/queries/useClearTaskInstances";
3136
import { useClearTaskInstancesDryRun } from "src/queries/useClearTaskInstancesDryRun";
3237

3338
type SelectedExecution = Pick<
3439
ExecutionTaskResponse,
35-
"id" | "map_index" | "region_id" | "region_index" | "task_display_name" | "task_id"
40+
"id" | "map_index" | "note" | "region_id" | "region_index" | "task_display_name" | "task_id"
3641
>;
3742

3843
export const ClearExecutionDialog = ({
@@ -49,10 +54,28 @@ export const ClearExecutionDialog = ({
4954
readonly runId: string;
5055
}) => {
5156
const { t: translate } = useTranslation("dag");
52-
const [downstream, setDownstream] = useState(true);
57+
const [defaultOptions] = useClearTaskInstanceDefaultOptions();
58+
const [preventRunningDefault] = useClearPreventRunningTaskDefault();
59+
const [keepTaskStateDefault] = useClearKeepTaskStateDefault();
60+
const initialNote = (executions.length === 1 ? executions[0]?.note : undefined) ?? "";
61+
const defaultDownstream = defaultOptions.includes("downstream");
62+
const [downstream, setDownstream] = useState(defaultDownstream);
5363
const [later, setLater] = useState(true);
5464
const [whole, setWhole] = useState(false);
55-
const [note, setNote] = useState<string>();
65+
const [preventRunning, setPreventRunning] = useState(preventRunningDefault);
66+
const [keepTaskState, setKeepTaskState] = useState(keepTaskStateDefault);
67+
const [note, setNote] = useState(initialNote);
68+
69+
useEffect(() => {
70+
if (!open) {
71+
setDownstream(defaultDownstream);
72+
setLater(true);
73+
setWhole(false);
74+
setPreventRunning(preventRunningDefault);
75+
setKeepTaskState(keepTaskStateDefault);
76+
setNote(initialNote);
77+
}
78+
}, [open, defaultDownstream, preventRunningDefault, keepTaskStateDefault, initialNote]);
5679
const mappedIds = executions
5780
.filter(
5881
(ti) =>
@@ -64,7 +87,9 @@ export const ClearExecutionDialog = ({
6487
dag_run_id: runId,
6588
include_downstream: downstream,
6689
include_later_loop_iterations: later,
90+
keep_task_state: keepTaskState,
6791
only_failed: false,
92+
prevent_running_task: preventRunning,
6893
task_instance_ids: executions.map((ti) => ti.id),
6994
whole_expansion_ids: whole ? mappedIds : [],
7095
};
@@ -81,7 +106,16 @@ export const ClearExecutionDialog = ({
81106
<Button
82107
disabled={preview.isPending || preview.isError}
83108
loading={clear.isPending}
84-
onClick={() => clear.mutate({ dagId, requestBody: { ...requestBody, dry_run: false, note } })}
109+
onClick={() =>
110+
clear.mutate({
111+
dagId,
112+
requestBody: {
113+
...requestBody,
114+
dry_run: false,
115+
note: note === initialNote ? undefined : note || undefined,
116+
},
117+
})
118+
}
85119
>
86120
{translate("execution.clearSelected")}
87121
</Button>
@@ -92,10 +126,10 @@ export const ClearExecutionDialog = ({
92126
}
93127
}}
94128
open={open}
95-
title={translate("execution.clearTitle")}
129+
title={translate("execution.clearSelected")}
96130
>
97131
<Stack gap={4}>
98-
<ErrorAlert error={preview.error ?? clear.error} />
132+
<ErrorAlert error={preview.error} />
99133
<Checkbox checked={downstream} onCheckedChange={(details) => setDownstream(details.checked === true)}>
100134
{translate("execution.clearDownstream")}
101135
</Checkbox>
@@ -107,13 +141,27 @@ export const ClearExecutionDialog = ({
107141
{translate("execution.clearWhole")}
108142
</Checkbox>
109143
) : undefined}
110-
<Text>{translate("execution.clearAffected", { count: preview.data?.total_entries ?? 0 })}</Text>
144+
<Checkbox
145+
checked={preventRunning}
146+
onCheckedChange={(details) => setPreventRunning(details.checked === true)}
147+
>
148+
{translate("dags:runAndTaskActions.options.preventRunningTasks")}
149+
</Checkbox>
150+
<Checkbox
151+
checked={keepTaskState}
152+
onCheckedChange={(details) => setKeepTaskState(details.checked === true)}
153+
>
154+
{translate("dags:runAndTaskActions.options.keepTaskState")}
155+
</Checkbox>
156+
{preview.data === undefined ? undefined : (
157+
<Text>{translate("execution.clearAffected", { count: preview.data.total_entries })}</Text>
158+
)}
111159
<Textarea
112160
aria-label={translate("execution.clearNote")}
113161
maxLength={1000}
114162
onChange={(event) => setNote(event.target.value)}
115163
placeholder={translate("execution.clearNote")}
116-
value={note ?? ""}
164+
value={note}
117165
/>
118166
</Stack>
119167
</Modal>

‎airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.test.tsx‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
* under the License.
1818
*/
1919
import "@testing-library/jest-dom";
20-
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
20+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
2121
import { http, HttpResponse } from "msw";
2222
import { setupServer, type SetupServer } from "msw/node";
2323
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -75,6 +75,14 @@ const taskInstance: TaskInstanceResponse = {
7575

7676
const affectedTasks = { task_instances: [taskInstance], total_entries: 1 };
7777

78+
const loopPass = (id: string, regionIndex: number): TaskInstanceResponse => ({
79+
...taskInstance,
80+
id,
81+
region_id: "11111111-1111-4111-8111-111111111111",
82+
region_index: regionIndex,
83+
task_id: "body.work",
84+
});
85+
7886
const mutateMock = vi.fn();
7987

8088
vi.mock("src/queries/useClearTaskInstances", () => ({
@@ -99,6 +107,8 @@ beforeAll(() => {
99107
server.listen({ onUnhandledRequest: "bypass" });
100108
});
101109
afterEach(() => {
110+
affectedTasks.task_instances = [taskInstance];
111+
mutateMock.mockClear();
102112
server.resetHandlers();
103113
localStorage.clear();
104114
});
@@ -126,4 +136,25 @@ describe("ClearTaskInstanceDialog", () => {
126136

127137
expect(requestBody.keep_task_state).toBe(true);
128138
});
139+
140+
it("clears the kept loop passes by execution id when another pass is unticked", async () => {
141+
affectedTasks.task_instances = [loopPass("pass-0", 0), loopPass("pass-2", 2)];
142+
143+
render(<ClearTaskInstanceDialog onClose={vi.fn()} open taskInstance={taskInstance} />, {
144+
wrapper: Wrapper,
145+
});
146+
147+
const rows = await screen.findAllByRole("row");
148+
149+
fireEvent.click(within(rows[1] as HTMLElement).getByRole("checkbox"));
150+
fireEvent.click(await screen.findByRole("button", { name: /modal\.confirm/iu }));
151+
152+
await waitFor(() => expect(mutateMock).toHaveBeenCalledTimes(1));
153+
const [{ requestBody }] = mutateMock.mock.calls[0] as [
154+
{ requestBody: { task_ids?: unknown; task_instance_ids?: Array<string> } },
155+
];
156+
157+
expect(requestBody.task_instance_ids).toEqual(["pass-2"]);
158+
expect(requestBody.task_ids).toBeUndefined();
159+
});
129160
});

‎airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,16 @@ const ClearTaskInstanceDialog = (props: Props) => {
340340
// instances by run and fire one run-scoped clear each, with the graph-expansion
341341
// flags off. This honors per-run exclusions (e.g. keep task X in run 1 but drop
342342
// it from run 2) that a single flat request cannot express.
343-
const idsByRun = new Map<string, NonNullable<ClearTaskInstancesBody["task_ids"]>>();
343+
const idsByRun = new Map<string, Array<string>>();
344344

345345
for (const ti of keptTaskInstances) {
346346
const ids = idsByRun.get(ti.dag_run_id) ?? [];
347347

348-
ids.push(ti.map_index < 0 ? ti.task_id : [ti.task_id, ti.map_index]);
348+
ids.push(ti.id);
349349
idsByRun.set(ti.dag_run_id, ids);
350350
}
351351

352-
for (const [runId, taskIds] of idsByRun) {
352+
for (const [runId, taskInstanceIds] of idsByRun) {
353353
mutate({
354354
dagId,
355355
requestBody: {
@@ -362,7 +362,7 @@ const ClearTaskInstanceDialog = (props: Props) => {
362362
note: noteChanged ? note : undefined,
363363
only_failed: onlyFailed,
364364
run_on_latest_version: runOnLatestVersion,
365-
task_ids: taskIds,
365+
task_instance_ids: taskInstanceIds,
366366
...(keepTaskState ? { keep_task_state: true } : {}),
367367
...(preventRunningTask ? { prevent_running_task: true } : {}),
368368
},

0 commit comments

Comments
 (0)