Skip to content

Commit 09e5c9d

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 5cdd01f commit 09e5c9d

88 files changed

Lines changed: 2768 additions & 268 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
@@ -457,6 +457,7 @@
457457
"executorConfig": "Executor Config",
458458
"hostname": "Hostname",
459459
"id": "ID",
460+
"iteration": "Iteration",
460461
"maxTries": "Max Tries",
461462
"pid": "PID",
462463
"pool": "Pool",

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,25 @@
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+
"clearTitle": "Clear selected executions",
79+
"clearWhole": "Clear whole mapped expansions",
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+
"nextPage": "Next page",
84+
"page": "Showing {{start}}–{{end}} of {{total}} executions",
85+
"previousPage": "Previous page",
86+
"runTasks": "Run tasks",
87+
"select": "Select {{task}}",
88+
"title": "Execution"
89+
},
7190
"extraLinks": "Extra Links",
7291
"grid": {
7392
"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
@@ -26,10 +26,10 @@ import { Checkbox } from "src/system-components";
2626
import type { MetaColumn } from "src/components/DataTable/types";
2727
import { StateBadge } from "src/components/StateBadge";
2828

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

3434
// When provided, rows render a leading checkbox so the user can exclude individual
3535
// task instances from the action. Excluded keys are tracked by the caller.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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 i18n from "src/i18n/config";
26+
import { Wrapper } from "src/utils/Wrapper";
27+
28+
import dagTranslations from "../../../../public/i18n/locales/en/dag.json";
29+
import { ClearExecutionDialog } from "./ClearExecutionDialog";
30+
31+
beforeAll(() => i18n.addResourceBundle("en", "dag", dagTranslations, true, true));
32+
afterEach(() => vi.restoreAllMocks());
33+
34+
it("keeps stale UUID scope visible when the clear preview rejects an archived execution", async () => {
35+
const clear = vi
36+
.spyOn(TaskInstanceService, "postClearTaskInstances")
37+
.mockRejectedValue({ body: { detail: "Selected execution was archived" }, status: 409 });
38+
const onClose = vi.fn();
39+
40+
render(
41+
<ClearExecutionDialog
42+
dagId="dag"
43+
executions={[
44+
{
45+
id: "archived-uuid",
46+
map_index: -1,
47+
region_id: "11111111-1111-1111-1111-111111111111",
48+
region_index: 3,
49+
task_display_name: "work",
50+
task_id: "body.work",
51+
},
52+
]}
53+
onClose={onClose}
54+
open
55+
runId="run"
56+
/>,
57+
{ wrapper: Wrapper },
58+
);
59+
expect(await screen.findByText("Selected execution was archived")).toBeVisible();
60+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeDisabled();
61+
expect(clear).toHaveBeenCalledTimes(1);
62+
expect(clear.mock.lastCall?.[0].requestBody).toMatchObject({
63+
dry_run: true,
64+
task_instance_ids: ["archived-uuid"],
65+
});
66+
expect(onClose).not.toHaveBeenCalled();
67+
});
68+
69+
it("previews and clears UUID seeds with separate whole-expansion and later-loop intent", async () => {
70+
const clear = vi
71+
.spyOn(TaskInstanceService, "postClearTaskInstances")
72+
.mockResolvedValue({ task_instances: [], total_entries: 0 });
73+
74+
render(
75+
<ClearExecutionDialog
76+
dagId="dag"
77+
executions={[
78+
{
79+
id: "selected-uuid",
80+
map_index: 1,
81+
region_id: "11111111-1111-1111-1111-111111111111",
82+
region_index: 1,
83+
task_display_name: "mapped",
84+
task_id: "body.mapped",
85+
},
86+
]}
87+
onClose={vi.fn()}
88+
open
89+
runId="run"
90+
/>,
91+
{ wrapper: Wrapper },
92+
);
93+
94+
await waitFor(() =>
95+
expect(clear.mock.lastCall?.[0]).toMatchObject({
96+
dagId: "dag",
97+
requestBody: {
98+
dag_run_id: "run",
99+
dry_run: true,
100+
include_downstream: true,
101+
include_later_loop_iterations: true,
102+
only_failed: false,
103+
task_instance_ids: ["selected-uuid"],
104+
whole_expansion_ids: [],
105+
},
106+
}),
107+
);
108+
fireEvent.click(screen.getByLabelText("Clear whole mapped expansions"));
109+
fireEvent.click(screen.getByLabelText("Clear later loop iterations"));
110+
await waitFor(() =>
111+
expect(clear.mock.lastCall?.[0]).toMatchObject({
112+
dagId: "dag",
113+
requestBody: {
114+
dry_run: true,
115+
include_later_loop_iterations: false,
116+
whole_expansion_ids: ["selected-uuid"],
117+
},
118+
}),
119+
);
120+
await waitFor(() =>
121+
expect(screen.getByRole("button", { name: "Clear selected executions" })).toBeEnabled(),
122+
);
123+
fireEvent.click(screen.getByRole("button", { name: "Clear selected executions" }));
124+
await waitFor(() =>
125+
expect(
126+
clear.mock.calls.map(([request]) => request).find((request) => request.requestBody.dry_run === false),
127+
).toMatchObject({
128+
dagId: "dag",
129+
requestBody: {
130+
dry_run: false,
131+
include_downstream: true,
132+
include_later_loop_iterations: false,
133+
task_instance_ids: ["selected-uuid"],
134+
whole_expansion_ids: ["selected-uuid"],
135+
},
136+
}),
137+
);
138+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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 { useState } from "react";
20+
21+
import { Button, Stack, Text, Textarea } from "@chakra-ui/react";
22+
import { useTranslation } from "react-i18next";
23+
24+
import type { ClearTaskInstancesBody, ExecutionTaskResponse } from "openapi/requests/types.gen";
25+
26+
import { Checkbox, Modal } from "src/system-components";
27+
28+
import { ErrorAlert } from "src/components/ErrorAlert";
29+
30+
import { useClearTaskInstances } from "src/queries/useClearTaskInstances";
31+
import { useClearTaskInstancesDryRun } from "src/queries/useClearTaskInstancesDryRun";
32+
33+
type SelectedExecution = Pick<
34+
ExecutionTaskResponse,
35+
"id" | "map_index" | "region_id" | "region_index" | "task_display_name" | "task_id"
36+
>;
37+
38+
export const ClearExecutionDialog = ({
39+
dagId,
40+
executions,
41+
onClose,
42+
open,
43+
runId,
44+
}: {
45+
readonly dagId: string;
46+
readonly executions: Array<SelectedExecution>;
47+
readonly onClose: () => void;
48+
readonly open: boolean;
49+
readonly runId: string;
50+
}) => {
51+
const { t: translate } = useTranslation("dag");
52+
const [downstream, setDownstream] = useState(true);
53+
const [later, setLater] = useState(true);
54+
const [whole, setWhole] = useState(false);
55+
const [note, setNote] = useState<string>();
56+
const mappedIds = executions
57+
.filter(
58+
(ti) =>
59+
ti.map_index >= 0 ||
60+
(ti.region_id !== "00000000-0000-0000-0000-000000000000" && ti.region_index === -1),
61+
)
62+
.map((ti) => ti.id);
63+
const requestBody: ClearTaskInstancesBody = {
64+
dag_run_id: runId,
65+
include_downstream: downstream,
66+
include_later_loop_iterations: later,
67+
only_failed: false,
68+
task_instance_ids: executions.map((ti) => ti.id),
69+
whole_expansion_ids: whole ? mappedIds : [],
70+
};
71+
const preview = useClearTaskInstancesDryRun({
72+
dagId,
73+
options: { enabled: open, retry: false },
74+
requestBody,
75+
});
76+
const clear = useClearTaskInstances({ dagId, dagRunId: runId, onSuccessConfirm: onClose });
77+
78+
return (
79+
<Modal
80+
footerActions={
81+
<Button
82+
disabled={preview.isPending || preview.isError}
83+
loading={clear.isPending}
84+
onClick={() => clear.mutate({ dagId, requestBody: { ...requestBody, dry_run: false, note } })}
85+
>
86+
{translate("execution.clearSelected")}
87+
</Button>
88+
}
89+
onOpenChange={(details) => {
90+
if (!details.open) {
91+
onClose();
92+
}
93+
}}
94+
open={open}
95+
title={translate("execution.clearTitle")}
96+
>
97+
<Stack gap={4}>
98+
<ErrorAlert error={preview.error ?? clear.error} />
99+
<Checkbox checked={downstream} onCheckedChange={(details) => setDownstream(details.checked === true)}>
100+
{translate("execution.clearDownstream")}
101+
</Checkbox>
102+
<Checkbox checked={later} onCheckedChange={(details) => setLater(details.checked === true)}>
103+
{translate("execution.clearLater")}
104+
</Checkbox>
105+
{mappedIds.length > 0 ? (
106+
<Checkbox checked={whole} onCheckedChange={(details) => setWhole(details.checked === true)}>
107+
{translate("execution.clearWhole")}
108+
</Checkbox>
109+
) : undefined}
110+
<Text>{translate("execution.clearAffected", { count: preview.data?.total_entries ?? 0 })}</Text>
111+
<Textarea
112+
aria-label={translate("execution.clearNote")}
113+
maxLength={1000}
114+
onChange={(event) => setNote(event.target.value)}
115+
placeholder={translate("execution.clearNote")}
116+
value={note ?? ""}
117+
/>
118+
</Stack>
119+
</Modal>
120+
);
121+
};

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { useClearTaskInstances } from "src/queries/useClearTaskInstances";
4141
import { useClearTaskInstancesDryRun } from "src/queries/useClearTaskInstancesDryRun";
4242
import { isStatePending, useAutoRefresh } from "src/utils";
4343

44+
import { ClearExecutionDialog } from "./ClearExecutionDialog";
4445
import ClearTaskInstanceConfirmationDialog from "./ClearTaskInstanceConfirmationDialog";
4546
import { getRunOnLatestVersionState } from "./runOnLatestVersion";
4647

@@ -399,4 +400,27 @@ const ClearTaskInstanceDialog = (props: Props) => {
399400
);
400401
};
401402

402-
export default ClearTaskInstanceDialog;
403+
const ScopedClearTaskInstanceDialog = (props: Props) => {
404+
const { allMapped } = props;
405+
406+
if (allMapped) {
407+
return <ClearTaskInstanceDialog {...props} />;
408+
}
409+
const { onClose, open, taskInstance } = props;
410+
411+
if (taskInstance.region_id !== "00000000-0000-0000-0000-000000000000") {
412+
return (
413+
<ClearExecutionDialog
414+
dagId={taskInstance.dag_id}
415+
executions={[taskInstance]}
416+
onClose={onClose}
417+
open={open}
418+
runId={taskInstance.dag_run_id}
419+
/>
420+
);
421+
}
422+
423+
return <ClearTaskInstanceDialog {...props} />;
424+
};
425+
426+
export default ScopedClearTaskInstanceDialog;

0 commit comments

Comments
 (0)