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 19d20a091dc UI: Add 'Mark as Skipped' option to Task Instance actions
(#72425)
19d20a091dc is described below
commit 19d20a091dc43cf65ce61082b5498350824fa64b
Author: ccoliu <[email protected]>
AuthorDate: Thu Oct 8 04:34:28 2026 +0800
UI: Add 'Mark as Skipped' option to Task Instance actions (#72425)
* UI: Add 'Mark as Skipped' option to Task Instance actions
* Format translate calls in MarkAs buttons to satisfy Prettier
* Restore blank line before return in MarkAs buttons to satisfy ESLint
The previous prettier auto-fix collapsed the translate() call but also
dropped the blank line before return, tripping
padding-line-between-statements.
* Trigger CI rerun (Firefox e2e flake)
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Limit Mark as Skipped to pending task instances
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
airflow-core/docs/core-concepts/tasks.rst | 23 +++++++-
.../airflow/ui/public/i18n/locales/en/dags.json | 1 +
.../TaskInstance/MarkTaskInstanceAsButton.tsx | 17 ++++--
.../TaskInstance/MarkTaskInstanceAsDialog.test.tsx | 69 +++++++++++++++++++---
.../TaskInstance/MarkTaskInstanceAsDialog.tsx | 26 +++++---
.../components/MarkAs/{utils.ts => utils.test.ts} | 30 +++++++++-
.../src/airflow/ui/src/components/MarkAs/utils.ts | 12 +++-
7 files changed, 152 insertions(+), 26 deletions(-)
diff --git a/airflow-core/docs/core-concepts/tasks.rst
b/airflow-core/docs/core-concepts/tasks.rst
index 0e125fda92d..e56fed21e37 100644
--- a/airflow-core/docs/core-concepts/tasks.rst
+++ b/airflow-core/docs/core-concepts/tasks.rst
@@ -78,7 +78,7 @@ The possible states for a Task Instance are:
* ``success``: The task finished running without errors
* ``restarting``: The task was externally requested to restart when it was
running
* ``failed``: The task had an error during execution and failed to run
-* ``skipped``: The task was skipped due to branching, LatestOnly, or similar.
+* ``skipped``: The task was skipped due to branching, LatestOnly, or similar,
or was :ref:`marked as skipped manually <concepts:task-mark-as-skipped>`.
* ``upstream_failed``: An upstream task failed and the :ref:`Trigger Rule
<concepts:trigger-rules>` says we needed it
* ``up_for_retry``: The task failed, but has retry attempts left and will be
rescheduled.
* ``up_for_reschedule``: The task is a :doc:`Sensor <sensors>` that is in
``reschedule`` mode
@@ -90,6 +90,27 @@ The possible states for a Task Instance are:
Ideally, a task should flow from ``none``, to ``scheduled``, to ``queued``, to
``running``, and finally to ``success``.
+.. _concepts:task-mark-as-skipped:
+
+Marking a Task Instance as skipped
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+From the UI, an operator can mark a Task Instance as ``skipped`` to record
that it was intentionally not
+run for a Dag Run, for example during incident recovery when an input it waits
for is known to never
+arrive. Unlike marking it as ``success``, this does not suggest the task's
work was done.
+
+Keep the following in mind:
+
+* Only Task Instances that have not started running can be marked as skipped:
those with no state, or in
+ the ``scheduled``, ``up_for_retry`` or ``up_for_reschedule`` state.
+* Only the selected Task Instance is affected; upstream, downstream, past and
future Task Instances are
+ not changed.
+* Marking a task as skipped does not bypass the Dag's dependencies. Downstream
tasks are still evaluated
+ according to their :ref:`trigger rules <concepts:trigger-rules>`. With the
default ``all_success`` rule,
+ the skip cascades and downstream tasks are skipped as well. For downstream
tasks to still run, they need
+ a trigger rule that tolerates skipped upstream tasks, such as
``none_failed`` or
+ ``none_failed_min_one_success``.
+
When any custom Task (Operator) is running, it will get a copy of the task
instance passed to it; as well as being able to inspect task metadata, it also
contains methods for things like :doc:`xcoms`.
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 237dc358f86..8ab7d375ac3 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -84,6 +84,7 @@
"failed": "Press shift+f to mark as failed",
"success": "Press shift+s to mark as success"
},
+ "skippedInfo": "Marking a task instance as skipped records that it was
intentionally not run. Downstream tasks are still evaluated by their trigger
rules: with the default \"all_success\" rule they will also be skipped. Only
task instances that have not started running can be skipped, and only this task
instance is affected.",
"title": "Mark {{type}} as {{state}}"
},
"options": {
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
index d6708e2ae5a..e4d3e24390b 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
@@ -33,7 +33,7 @@ import { StateBadge } from "src/components/StateBadge";
import { SHORTCUTS } from "src/context/keyboardShortcuts";
import { useShortcut } from "src/hooks/useShortcut";
-import { allowedStates } from "../utils";
+import { allowedStates, canMarkTaskInstanceAsSkipped } from "../utils";
import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
type Props = {
@@ -69,6 +69,10 @@ export const MarkTaskInstanceAsButton = ({ isHotkeyEnabled =
false, taskInstance
type: translate("taskInstance_one"),
});
+ const menuStates: Array<TaskInstanceState> =
canMarkTaskInstanceAsSkipped(taskInstance.state)
+ ? [...allowedStates, "skipped"]
+ : allowedStates;
+
return (
<div>
<Menu.Root positioning={{ gutter: 0, placement: "bottom" }}
tooltipLabel={label}>
@@ -82,16 +86,17 @@ export const MarkTaskInstanceAsButton = ({ isHotkeyEnabled
= false, taskInstance
</IconButton>
</Menu.Trigger>
<Menu.Content>
- {allowedStates.map((menuState) => {
- const content = translate(
- `dags:runAndTaskActions.markAs.buttonTooltip.${menuState ===
"success" ? "success" : "failed"}`,
- );
+ {menuStates.map((menuState) => {
+ const hasShortcut = menuState === "success" || menuState ===
"failed";
+ const content = hasShortcut
+ ?
translate(`dags:runAndTaskActions.markAs.buttonTooltip.${menuState}`)
+ : undefined;
return (
<Tooltip
closeDelay={100}
content={content}
- disabled={!isHotkeyEnabled}
+ disabled={!isHotkeyEnabled || !hasShortcut}
key={menuState}
openDelay={100}
>
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
index e519ec3913c..cb5123abeaa 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.test.tsx
@@ -18,14 +18,25 @@
*/
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import type * as UserSettings from "src/hooks/useUserSettings";
import { Wrapper } from "src/utils/Wrapper";
import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
+const mocks = vi.hoisted(() => ({
+ defaultOptions: [] as Array<string>,
+ dryRun: vi.fn(),
+}));
+
+vi.mock("src/hooks/useUserSettings", async (importOriginal) => ({
+ ...(await importOriginal<typeof UserSettings>()),
+ useMarkTaskInstanceDefaultOptions: () => [mocks.defaultOptions, vi.fn()],
+}));
+
vi.mock("src/queries/usePatchTaskInstance", () => ({
usePatchTaskInstance: () => ({
isPending: false,
@@ -34,15 +45,24 @@ vi.mock("src/queries/usePatchTaskInstance", () => ({
}));
vi.mock("src/queries/usePatchTaskInstanceDryRun", () => ({
- usePatchTaskInstanceDryRun: () => ({
- data: {
- task_instances: [],
- total_entries: 0,
- },
- isPending: false,
- }),
+ usePatchTaskInstanceDryRun: (args: unknown) => {
+ mocks.dryRun(args);
+
+ return {
+ data: {
+ task_instances: [],
+ total_entries: 0,
+ },
+ isPending: false,
+ };
+ },
}));
+afterEach(() => {
+ mocks.defaultOptions = [];
+ mocks.dryRun.mockClear();
+});
+
const taskInstance: TaskInstanceResponse = {
dag_display_name: "Test DAG",
dag_id: "test_dag",
@@ -88,4 +108,37 @@ describe("MarkTaskInstanceAsDialog", () => {
expect(screen.getByRole("button", { name: /downstream/iu
})).not.toHaveAttribute("data-selected");
});
+
+ it("does not show the skipped info for other states", () => {
+ render(<MarkTaskInstanceAsDialog onClose={vi.fn()} open state="success"
taskInstance={taskInstance} />, {
+ wrapper: Wrapper,
+ });
+
+
expect(screen.queryByText("dags:runAndTaskActions.markAs.skippedInfo")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /downstream/iu })).toBeEnabled();
+ });
+
+ it("shows the info text and limits skipping to the selected task instance",
() => {
+ mocks.defaultOptions = ["past", "future", "upstream", "downstream"];
+
+ render(<MarkTaskInstanceAsDialog onClose={vi.fn()} open state="skipped"
taskInstance={taskInstance} />, {
+ wrapper: Wrapper,
+ });
+
+
expect(screen.getByText("dags:runAndTaskActions.markAs.skippedInfo")).toBeInTheDocument();
+ for (const option of [/past/iu, /future/iu, /upstream/iu, /downstream/iu])
{
+ expect(screen.getByRole("button", { name: option })).toBeDisabled();
+ }
+ expect(mocks.dryRun).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ requestBody: expect.objectContaining({
+ include_downstream: false,
+ include_future: false,
+ include_past: false,
+ include_upstream: false,
+ new_state: "skipped",
+ }) as unknown,
+ }),
+ );
+ });
});
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
index 4567cdaf598..71bef2fc8f6 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
@@ -23,7 +23,7 @@ import { useTranslation } from "react-i18next";
import type { TaskInstanceResponse, TaskInstanceState } from
"openapi/requests/types.gen";
-import { Modal, SegmentedControl } from "src/system-components";
+import { Alert, Modal, SegmentedControl } from "src/system-components";
import { ActionAccordion } from "src/components/ActionAccordion";
import { StateBadge } from "src/components/StateBadge";
@@ -50,10 +50,12 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state,
taskInstance }: Props)
const [markTaskInstanceDefaultOptions] = useMarkTaskInstanceDefaultOptions();
const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(markTaskInstanceDefaultOptions);
- const past = selectedOptions.includes("past");
- const future = selectedOptions.includes("future");
- const upstream = selectedOptions.includes("upstream");
- const downstream = selectedOptions.includes("downstream");
+ // Skipping is limited to this task instance, so the options affecting other
task instances are ignored.
+ const isSkipped = state === "skipped";
+ const past = !isSkipped && selectedOptions.includes("past");
+ const future = !isSkipped && selectedOptions.includes("future");
+ const upstream = !isSkipped && selectedOptions.includes("upstream");
+ const downstream = !isSkipped && selectedOptions.includes("downstream");
const [note, setNote] = useState<string | null>(taskInstance.note);
@@ -144,31 +146,39 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state,
taskInstance }: Props)
>
<Flex justifyContent="center">
<SegmentedControl
- defaultValues={markTaskInstanceDefaultOptions}
+ defaultValues={isSkipped ? [] : markTaskInstanceDefaultOptions}
+ key={isSkipped ? "skipped" : "default"}
multiple
onChange={setSelectedOptions}
options={[
{
- disabled: taskInstance.logical_date === null,
+ disabled: isSkipped || taskInstance.logical_date === null,
label: translate("dags:runAndTaskActions.options.past"),
value: "past",
},
{
- disabled: taskInstance.logical_date === null,
+ disabled: isSkipped || taskInstance.logical_date === null,
label: translate("dags:runAndTaskActions.options.future"),
value: "future",
},
{
+ disabled: isSkipped,
label: translate("dags:runAndTaskActions.options.upstream"),
value: "upstream",
},
{
+ disabled: isSkipped,
label: translate("dags:runAndTaskActions.options.downstream"),
value: "downstream",
},
]}
/>
</Flex>
+ {isSkipped ? (
+ <Alert mb={3} status="info">
+ {translate("dags:runAndTaskActions.markAs.skippedInfo")}
+ </Alert>
+ ) : undefined}
<ActionAccordion affectedTasks={affectedTasks} note={note}
setNote={setNote} />
</Modal>
);
diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
similarity index 52%
copy from airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
copy to airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
index 5d62fd27b94..6f95cc8f14a 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.test.ts
@@ -16,6 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { DagRunMutableStates } from "openapi/requests/types.gen";
+import { describe, expect, it } from "vitest";
-export const allowedStates: Array<DagRunMutableStates> = ["success", "failed"];
+import type { TaskInstanceState } from "openapi/requests/types.gen";
+
+import { canMarkTaskInstanceAsSkipped } from "./utils";
+
+describe("canMarkTaskInstanceAsSkipped", () => {
+ it.each([null, undefined, "scheduled", "up_for_reschedule", "up_for_retry"]
as const)(
+ "allows %s",
+ (state) => {
+ expect(canMarkTaskInstanceAsSkipped(state)).toBe(true);
+ },
+ );
+
+ it.each<TaskInstanceState>([
+ "queued",
+ "running",
+ "deferred",
+ "restarting",
+ "awaiting_input",
+ "success",
+ "failed",
+ "skipped",
+ "upstream_failed",
+ "removed",
+ ])("does not allow %s", (state) => {
+ expect(canMarkTaskInstanceAsSkipped(state)).toBe(false);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
index 5d62fd27b94..2bb60e015ef 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/utils.ts
@@ -16,6 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { DagRunMutableStates } from "openapi/requests/types.gen";
+import type { DagRunMutableStates, TaskInstanceState } from
"openapi/requests/types.gen";
export const allowedStates: Array<DagRunMutableStates> = ["success", "failed"];
+
+// Narrower than isStatePending: excludes states where the task has started or
may start at any moment.
+const skippableTaskInstanceStates: Array<TaskInstanceState> = [
+ "scheduled",
+ "up_for_reschedule",
+ "up_for_retry",
+];
+
+export const canMarkTaskInstanceAsSkipped = (state?: TaskInstanceState | null)
=>
+ state === null || state === undefined ||
skippableTaskInstanceStates.includes(state);