Skip to content

Commit ca74c05

Browse files
committed
Show current task executions in the UI
A loop pass or a mapped expansion reuses a task id and map index, so the UI cannot identify an execution by (task_id, map_index): the logs, XCom, state or HITL page opened for one pass could show another's. Pages and mutations therefore carry the region id and index, and row selection is keyed by attempt UUID so two executions that share public coordinates stay distinct even when they are selected together. The run-level Execution tab lists live task instances grouped by loop iteration and mapped expansion, because that is how a user thinks about what is running. Clearing a selection goes by UUID through a dialog that offers the later-iteration and whole-expansion choices the clear API takes, so a user can decide how far a clear reaches. Gantt segments link to the exact region and try, and a retired try opens only when both are given, since a try number alone is ambiguous. Only the current view is covered. Selecting history by clear boundary is deliberately absent, so no trajectory parameter is read or written.
1 parent 3f0b2a0 commit ca74c05

97 files changed

Lines changed: 3293 additions & 290 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@
464464
"executorConfig": "Executor Config",
465465
"hostname": "Hostname",
466466
"id": "ID",
467+
"iteration": "Iteration",
467468
"maxTries": "Max Tries",
468469
"pid": "PID",
469470
"pool": "Pool",

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,23 @@
6868
"upcomingCount_one": "{{count}} Upcoming Deadline",
6969
"upcomingCount_other": "{{count}} Upcoming Deadlines"
7070
},
71+
"execution": {
72+
"clearAffected_one": "{{count}} execution will be cleared.",
73+
"clearAffected_other": "{{count}} executions will be cleared.",
74+
"clearDownstream": "Clear downstream tasks",
75+
"clearLater": "Clear later loop iterations",
76+
"clearNote": "Reason for clearing (optional)",
77+
"clearSelected": "Clear selected executions",
78+
"clearWhole": "Clear whole mapped expansions",
79+
"empty": "No executions in this run.",
80+
"iteration": "{{node}} · Iteration {{index}}",
81+
"mapped_one": "{{task}} ({{count}} mapped task on this page)",
82+
"mapped_other": "{{task}} ({{count}} mapped tasks on this page)",
83+
"page": "Showing {{start}}–{{end}} of {{total}} executions",
84+
"runTasks": "Run tasks",
85+
"select": "Select {{task}}",
86+
"title": "Execution"
87+
},
7188
"extraLinks": "Extra Links",
7289
"grid": {
7390
"buttons": {

‎airflow-core/src/airflow/ui/src/components/ActionAccordion/columns.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ import type { DataTableFeatures } from "src/components/DataTable/features";
2727
import type { MetaColumn } from "src/components/DataTable/types";
2828
import { StateBadge } from "src/components/StateBadge";
2929

30-
// Stable per-row key; dag_run_id keeps the same task distinct across runs (past/future
31-
// expansion), and map_index disambiguates the mapped instances of one task.
3230
export const taskInstanceKey = (ti: TaskInstanceResponse): string =>
33-
`${ti.dag_run_id}:${ti.task_id}:${ti.map_index}`;
31+
ti.region_id === "00000000-0000-0000-0000-000000000000"
32+
? `${ti.dag_run_id}:${ti.task_id}:${ti.map_index}`
33+
: ti.id;
3434

3535
// When provided, rows render a leading checkbox so the user can exclude individual
3636
// task instances from the action. Excluded keys are tracked by the caller.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import "@testing-library/jest-dom/vitest";
20+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
21+
import { afterEach, beforeAll, expect, it, vi } from "vitest";
22+
23+
import { TaskInstanceService } from "openapi/requests";
24+
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";
30+
import i18n from "src/i18n/config";
31+
import { Wrapper } from "src/utils/Wrapper";
32+
33+
import dagTranslations from "../../../../public/i18n/locales/en/dag.json";
34+
import dagsTranslations from "../../../../public/i18n/locales/en/dags.json";
35+
import { ClearExecutionDialog } from "./ClearExecutionDialog";
36+
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);
60+
61+
it("keeps stale UUID scope visible when the clear preview rejects an archived execution", async () => {
62+
const clear = vi
63+
.spyOn(TaskInstanceService, "postClearTaskInstances")
64+
.mockRejectedValue({ body: { detail: "Selected execution was archived" }, status: 409 });
65+
const onClose = vi.fn();
66+
67+
render(
68+
<ClearExecutionDialog
69+
dagId="dag"
70+
executions={[
71+
{
72+
id: "archived-uuid",
73+
map_index: -1,
74+
region_id: "11111111-1111-1111-1111-111111111111",
75+
region_index: 3,
76+
task_display_name: "work",
77+
task_id: "body.work",
78+
},
79+
]}
80+
onClose={onClose}
81+
open
82+
runId="run"
83+
/>,
84+
{ wrapper: Wrapper },
85+
);
86+
expect(await screen.findByText("Selected execution was archived")).toBeVisible();
87+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeDisabled();
88+
expect(clear).toHaveBeenCalledTimes(1);
89+
expect(clear.mock.lastCall?.[0].requestBody).toMatchObject({
90+
dry_run: true,
91+
task_instance_ids: ["archived-uuid"],
92+
});
93+
expect(onClose).not.toHaveBeenCalled();
94+
});
95+
96+
it("previews and clears UUID seeds with separate whole-expansion and later-loop intent", async () => {
97+
const clear = vi
98+
.spyOn(TaskInstanceService, "postClearTaskInstances")
99+
.mockResolvedValue({ task_instances: [], total_entries: 0 });
100+
101+
render(
102+
<ClearExecutionDialog
103+
dagId="dag"
104+
executions={[
105+
{
106+
id: "selected-uuid",
107+
map_index: 1,
108+
region_id: "11111111-1111-1111-1111-111111111111",
109+
region_index: 1,
110+
task_display_name: "mapped",
111+
task_id: "body.mapped",
112+
},
113+
]}
114+
onClose={vi.fn()}
115+
open
116+
runId="run"
117+
/>,
118+
{ wrapper: Wrapper },
119+
);
120+
121+
await waitFor(() =>
122+
expect(clear.mock.lastCall?.[0]).toMatchObject({
123+
dagId: "dag",
124+
requestBody: {
125+
dag_run_id: "run",
126+
dry_run: true,
127+
include_downstream: true,
128+
include_later_loop_iterations: true,
129+
only_failed: false,
130+
task_instance_ids: ["selected-uuid"],
131+
whole_expansion_ids: [],
132+
},
133+
}),
134+
);
135+
fireEvent.click(screen.getByLabelText("Clear whole mapped expansions"));
136+
fireEvent.click(screen.getByLabelText("Clear later loop iterations"));
137+
await waitFor(() =>
138+
expect(clear.mock.lastCall?.[0]).toMatchObject({
139+
dagId: "dag",
140+
requestBody: {
141+
dry_run: true,
142+
include_later_loop_iterations: false,
143+
whole_expansion_ids: ["selected-uuid"],
144+
},
145+
}),
146+
);
147+
await waitFor(() =>
148+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeEnabled(),
149+
);
150+
fireEvent.click(screen.getByRole("button", { name: "Clear selected executions" }));
151+
await waitFor(() =>
152+
expect(
153+
clear.mock.calls.map(([request]) => request).find((request) => request.requestBody.dry_run === false),
154+
).toMatchObject({
155+
dagId: "dag",
156+
requestBody: {
157+
dry_run: false,
158+
include_downstream: true,
159+
include_later_loop_iterations: false,
160+
task_instance_ids: ["selected-uuid"],
161+
whole_expansion_ids: ["selected-uuid"],
162+
},
163+
}),
164+
);
165+
});
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+
});

0 commit comments

Comments
 (0)