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 d8074062e6e Show backfill Dag runs in the UI (#70159)
d8074062e6e is described below
commit d8074062e6e32f20ca2ceebbfab8de7e52784163
Author: Shivam Rastogi <[email protected]>
AuthorDate: Wed Jul 29 08:27:00 2026 -0700
Show backfill Dag runs in the UI (#70159)
* UI: Show backfill Dag runs and progress
Backfill histories currently expose only high-level configuration, which
makes it hard to distinguish scheduled, failed, and skipped slots after a
backfill starts.
* Document backfill Dag run visibility
The user-visible detail page needs an Airflow feature newsfragment.
* Simplify backfill Dag run status UI
* Address backfill detail review feedback
* Refresh backfill views after actions
* Clarify backfill Dag run creation reasons
* Refine backfill Dag runs UI as cover dialog
* Remove backfill UI newsfragment
* Keep backfill slot views current and linkable
Backfill slot dialogs must remain shareable and show final Dag run states
without polling completed views indefinitely.
* Improve backfill slot dialog accessibility
---
.../airflow/ui/public/i18n/locales/en/common.json | 4 +
.../ui/public/i18n/locales/en/components.json | 9 +-
.../src/components/Banner/BackfillBanner.test.tsx | 92 +++++
.../ui/src/components/Banner/BackfillBanner.tsx | 8 +-
.../pages/Dag/Backfills/BackfillDagRunsModal.tsx | 219 ++++++++++++
.../ui/src/pages/Dag/Backfills/Backfills.test.tsx | 395 +++++++++++++++++++++
.../ui/src/pages/Dag/Backfills/Backfills.tsx | 56 ++-
airflow-core/src/airflow/ui/src/router.tsx | 1 +
.../src/airflow/ui/tests/e2e/pages/BackfillPage.ts | 13 +-
9 files changed, 779 insertions(+), 18 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index f182cd88b68..7db7ed5306c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -86,6 +86,7 @@
"dagRun_one": "Dag Run",
"dagRun_other": "Dag Runs",
"dagRunId": "Dag Run ID",
+ "dagRunState": "Dag Run State",
"dagWarnings": "Dag warnings/errors",
"defaultToGraphView": "Default to graph view",
"defaultToGridView": "Default to grid view",
@@ -288,6 +289,9 @@
},
"showDetailsPanel": "Show Details Panel",
"signedInAs": "Signed in as",
+ "slot": "Slot",
+ "slot_one": "Slot",
+ "slot_other": "Slots",
"source": {
"hide": "Hide Source",
"hotkey": "s",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
index dc51b203cd0..08490d89f8d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/components.json
@@ -7,9 +7,15 @@
"backwards": "Run Backwards",
"dateRange": "Date Range",
"errorStartDateBeforeEndDate": "Start Date must be before the End Date",
+ "exceptionReason": {
+ "alreadyExists": "Already exists",
+ "inFlight": "In flight",
+ "unknown": "Unknown"
+ },
"maxRuns": "Max Active Runs",
"missingAndErroredRuns": "Missing and Errored Runs",
"missingRuns": "Missing Runs",
+ "notCreatedReason": "Not Created Reason",
"overrideExistingParams": "Override parameters on existing runs",
"permissionDenied": "Dry Run Failed: User does not have permission to
create backfills.",
"reprocessBehavior": "Reprocess Behavior",
@@ -29,7 +35,8 @@
"validation": {
"datesRequired": "Both Data Interval Start Date and End Date must be
provided.",
"startBeforeEnd": "Data Interval Start Date must be less than or equal
to Data Interval End Date."
- }
+ },
+ "viewSlots": "View slots for Backfill #{{id}}"
},
"banner": {
"backfillInProgress": "Backfill in progress",
diff --git
a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
new file mode 100644
index 00000000000..3c34a38d178
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
@@ -0,0 +1,92 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { BackfillResponse } from "openapi/requests/types.gen";
+import type * as Utils from "src/utils";
+import { Wrapper } from "src/utils/Wrapper";
+
+import BackfillBanner from "./BackfillBanner";
+
+const mocks = vi.hoisted(() => ({
+ cancelBackfill: vi.fn(),
+ listBackfills: vi.fn(),
+ pauseBackfill: vi.fn(),
+ unpauseBackfill: vi.fn(),
+}));
+
+vi.mock("openapi/queries", () => ({
+ useBackfillServiceCancelBackfill: () => ({ isPending: false, mutate:
mocks.cancelBackfill }),
+ useBackfillServiceListBackfillsUi: mocks.listBackfills,
+ useBackfillServiceListBackfillsUiKey: "BackfillServiceListBackfillsUi",
+ useBackfillServicePauseBackfill: () => ({ isPending: false, mutate:
mocks.pauseBackfill }),
+ useBackfillServiceUnpauseBackfill: () => ({ isPending: false, mutate:
mocks.unpauseBackfill }),
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string) => (key === "banner.backfillInProgress" ? "Backfill in
progress" : key),
+ }),
+}));
+
+vi.mock("src/components/Time", () => ({
+ default: ({ datetime }: { readonly datetime: string }) =>
<span>{datetime}</span>,
+}));
+
+vi.mock("src/utils", async (importOriginal) => {
+ const actual = await importOriginal<typeof Utils>();
+
+ return { ...actual, useAutoRefresh: () => 5000 };
+});
+
+const backfill: BackfillResponse = {
+ completed_at: null,
+ created_at: "2026-07-01T00:00:00Z",
+ dag_display_name: "Example Dag",
+ dag_id: "example_dag",
+ dag_run_conf: null,
+ from_date: "2026-07-01T00:00:00Z",
+ id: 7,
+ is_paused: false,
+ max_active_runs: 4,
+ reprocess_behavior: "failed",
+ to_date: "2026-07-05T00:00:00Z",
+ updated_at: "2026-07-01T00:00:00Z",
+};
+
+describe("BackfillBanner", () => {
+ beforeEach(() => mocks.listBackfills.mockReset());
+
+ it("links the active banner to the Backfills tab", () => {
+ mocks.listBackfills.mockReturnValue({
+ data: { backfills: [backfill], total_entries: 1 },
+ isLoading: false,
+ });
+
+ render(<BackfillBanner dagId="example_dag" />, { wrapper: Wrapper });
+
+ expect(screen.getByRole("link", { name: "Backfill in progress:"
})).toHaveAttribute(
+ "href",
+ "/dags/example_dag/backfills",
+ );
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.tsx
b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.tsx
index 2aeba002ae3..eef42a34779 100644
--- a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.tsx
@@ -30,11 +30,10 @@ import {
useBackfillServiceUnpauseBackfill,
} from "openapi/queries";
import type { BackfillResponse } from "openapi/requests/types.gen";
-import { Tooltip } from "src/components/ui";
+import { RouterLink, Tooltip } from "src/components/ui";
import { useAutoRefresh } from "src/utils";
import Time from "../Time";
-import { ProgressBar } from "../ui";
type Props = {
readonly dagId: string;
@@ -107,7 +106,9 @@ const BackfillBanner = ({ dagId }: Props) => {
<Box bg="info.solid" borderRadius="full" color="info.contrast" my="1"
px="2" py="1">
<HStack alignItems="center" ml={3}>
<RiArrowGoBackFill />
- <Text key="backfill">{translate("banner.backfillInProgress")}:</Text>
+ <RouterLink color="inherit" fontWeight="medium"
to={`/dags/${dagId}/backfills`}>
+ {translate("banner.backfillInProgress")}:
+ </RouterLink>
<Tooltip content={translate("backfill.schedulerPriorityHint")}
showArrow>
<span>
<MdInfo />
@@ -119,7 +120,6 @@ const BackfillBanner = ({ dagId }: Props) => {
</Text>
<Spacer flex="max-content" />
- <ProgressBar size="xs" visibility="visible" />
<Button
aria-label={backfill.is_paused ? translate("banner.unpause") :
translate("banner.pause")}
loading={isPausePending || isUnPausePending}
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
new file mode 100644
index 00000000000..77236913d15
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/BackfillDagRunsModal.tsx
@@ -0,0 +1,219 @@
+/*!
+ * 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 { Heading, Text } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { useBackfillServiceGetBackfill, useBackfillServiceListBackfillDagRuns
} from "openapi/queries";
+import type { BackfillDagRunResponse } from "openapi/requests/types.gen";
+import { DataTable } from "src/components/DataTable";
+import type { TableState } from "src/components/DataTable/types";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StateBadge } from "src/components/StateBadge";
+import Time from "src/components/Time";
+import { Dialog, RouterLink } from "src/components/ui";
+import { useConfig } from "src/queries/useConfig";
+import { useAutoRefresh } from "src/utils";
+
+type BackfillDagRunsModalProps = {
+ readonly backfillId: number | undefined;
+ readonly dagId: string;
+ readonly onClose: () => void;
+ readonly open: boolean;
+};
+
+const translateExceptionReason = (reason: string, translate: (key: string) =>
string) => {
+ // Keep this mapping in sync with BackfillDagRunExceptionReason in
airflow.models.backfill.
+ switch (reason) {
+ case "already exists":
+ return translate("components:backfill.exceptionReason.alreadyExists");
+ case "in flight":
+ return translate("components:backfill.exceptionReason.inFlight");
+ case "unknown":
+ return translate("components:backfill.exceptionReason.unknown");
+ default:
+ return reason;
+ }
+};
+
+const isPendingDagRun = ({ dag_run_state: state, exception_reason: reason }:
BackfillDagRunResponse) =>
+ reason === null && state !== "failed" && state !== "success";
+
+const getColumns = (
+ isPartitioned: boolean,
+ translate: (key: string) => string,
+): Array<ColumnDef<BackfillDagRunResponse>> => [
+ {
+ accessorKey: isPartitioned ? "partition_key" : "logical_date",
+ cell: ({ row }) => {
+ if (isPartitioned) {
+ return row.original.partition_key === null ||
row.original.partition_key === "" ? (
+ <Text color="fg.muted">—</Text>
+ ) : (
+ <Text>{row.original.partition_key}</Text>
+ );
+ }
+
+ if (row.original.logical_date !== null && row.original.logical_date !==
"") {
+ return (
+ <Text>
+ <Time datetime={row.original.logical_date} />
+ </Text>
+ );
+ }
+
+ return <Text color="fg.muted">—</Text>;
+ },
+ enableSorting: false,
+ header: translate(isPartitioned ? "dagRun.partitionKey" : "logicalDate"),
+ },
+ {
+ accessorKey: "dag_run_state",
+ cell: ({ row }) => {
+ const state = row.original.dag_run_state;
+
+ if (state === null || state === undefined) {
+ return <Text color="fg.muted">—</Text>;
+ }
+
+ return <StateBadge
state={state}>{translate(`states.${state}`)}</StateBadge>;
+ },
+ enableSorting: false,
+ header: translate("dagRunState"),
+ },
+ {
+ accessorKey: "exception_reason",
+ cell: ({ row }) => {
+ const reason = row.original.exception_reason;
+
+ if (reason === null || reason === "") {
+ return <Text color="fg.muted">—</Text>;
+ }
+
+ return <Text>{translateExceptionReason(reason, translate)}</Text>;
+ },
+ enableSorting: false,
+ header: translate("components:backfill.notCreatedReason"),
+ },
+ {
+ accessorKey: "dag_run_id",
+ cell: ({ row }) => {
+ const runId = row.original.dag_run_id;
+
+ if (runId === null || runId === undefined || runId === "") {
+ return <Text color="fg.muted">—</Text>;
+ }
+
+ return (
+ <RouterLink fontWeight="bold"
to={`/dags/${row.original.dag_id}/runs/${runId}`}>
+ {runId}
+ </RouterLink>
+ );
+ },
+ enableSorting: false,
+ header: translate("runId"),
+ },
+ {
+ accessorKey: "sort_ordinal",
+ enableSorting: false,
+ header: "#",
+ },
+];
+
+export const BackfillDagRunsModal = ({ backfillId, dagId, onClose, open }:
BackfillDagRunsModalProps) => {
+ const { t: translate } = useTranslation();
+ const pageSize = (useConfig("fallback_page_limit") as number | undefined) ??
100;
+ const [pageIndex, setPageIndex] = useState(0);
+ const tableState = {
+ pagination: {
+ pageIndex,
+ pageSize,
+ },
+ sorting: [],
+ } satisfies TableState;
+ const refetchInterval = useAutoRefresh({ dagId });
+
+ const {
+ data: backfill,
+ error: backfillError,
+ isLoading: isBackfillLoading,
+ } = useBackfillServiceGetBackfill({ backfillId: backfillId ?? 0 },
undefined, {
+ enabled: open && backfillId !== undefined,
+ refetchInterval: (query) => (query.state.data?.completed_at === null ?
refetchInterval : false),
+ });
+ const shouldPoll = backfill?.completed_at === null;
+
+ const { data, error, isFetching, isLoading } =
useBackfillServiceListBackfillDagRuns(
+ {
+ backfillId: backfillId ?? 0,
+ limit: pageSize,
+ offset: pageIndex * pageSize,
+ },
+ undefined,
+ {
+ enabled: open && backfillId !== undefined,
+ refetchInterval: (query) =>
+ shouldPoll ||
query.state.data?.backfill_dag_runs.some(isPendingDagRun) ? refetchInterval :
false,
+ },
+ );
+ const isPartitioned =
+ data?.backfill_dag_runs[0]?.partition_key !== null &&
+ data?.backfill_dag_runs[0]?.partition_key !== undefined;
+
+ const handleOpenChange = () => {
+ setPageIndex(0);
+ onClose();
+ };
+
+ return (
+ <Dialog.Root
+ lazyMount
+ onOpenChange={handleOpenChange}
+ open={open}
+ scrollBehavior="inside"
+ size="cover"
+ unmountOnExit
+ >
+ <Dialog.Content backdrop>
+ <Dialog.Header>
+ <Heading size="md">
+ {translate("common:backfill_one")} #{backfillId}
+ </Heading>
+ </Dialog.Header>
+ <Dialog.CloseTrigger />
+ <Dialog.Body>
+ <ErrorAlert error={backfillError} />
+ <ErrorAlert error={error} />
+ <DataTable
+ columns={getColumns(isPartitioned, translate)}
+ data={data?.backfill_dag_runs ?? []}
+ initialState={tableState}
+ isFetching={isFetching}
+ isLoading={isBackfillLoading || isLoading}
+ modelName="common:slot"
+ onStateChange={(state) => setPageIndex(state.pagination.pageIndex)}
+ showRowCountHeading
+ total={data?.total_entries ?? 0}
+ />
+ </Dialog.Body>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
new file mode 100644
index 00000000000..824121bb042
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.test.tsx
@@ -0,0 +1,395 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ waitForElementToBeRemoved,
+ within,
+} from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { BackfillDagRunResponse, BackfillResponse } from
"openapi/requests/types.gen";
+import type * as Utils from "src/utils";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Backfills } from "./Backfills";
+
+const mocks = vi.hoisted(() => ({
+ getBackfill: vi.fn(),
+ listBackfillDagRuns: vi.fn(),
+ listBackfills: vi.fn(),
+}));
+
+vi.mock("openapi/queries", () => ({
+ useBackfillServiceGetBackfill: mocks.getBackfill,
+ useBackfillServiceListBackfillDagRuns: mocks.listBackfillDagRuns,
+ useBackfillServiceListBackfillsUi: mocks.listBackfills,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ // eslint-disable-next-line id-length
+ t: (key: string, options?: { id?: number }) =>
+ key === "components:backfill.viewSlots"
+ ? `View slots for Backfill #${options?.id}`
+ : ({
+ "common:backfill_one": "Backfill",
+ "common:slot": "Slots",
+ "components:backfill.exceptionReason.alreadyExists": "Already
exists",
+ "components:backfill.exceptionReason.inFlight": "In flight",
+ "components:backfill.exceptionReason.unknown": "Unknown",
+ "components:backfill.notCreatedReason": "Not Created Reason",
+ "dagRun.partitionKey": "Partition Key",
+ dagRunState: "Dag Run State",
+ logicalDate: "Logical Date",
+ "states.success": "Success",
+ }[key] ?? key),
+ }),
+}));
+
+vi.mock("src/components/Time", () => ({
+ default: ({ datetime }: { readonly datetime: string | null }) =>
<span>{datetime}</span>,
+}));
+
+vi.mock("src/queries/useConfig", () => ({
+ useConfig: (key: string) => (key === "fallback_page_limit" ? 25 : undefined),
+}));
+
+vi.mock("src/utils", async (importOriginal) => {
+ const actual = await importOriginal<typeof Utils>();
+
+ return { ...actual, useAutoRefresh: () => 5000 };
+});
+
+const makeBackfill = (overrides: Partial<BackfillResponse> = {}):
BackfillResponse => ({
+ completed_at: null,
+ created_at: "2026-06-30T00:00:00Z",
+ dag_display_name: "Example Dag",
+ dag_id: "example_dag",
+ dag_run_conf: null,
+ from_date: "2026-07-01T00:00:00Z",
+ id: 7,
+ is_paused: false,
+ max_active_runs: 4,
+ reprocess_behavior: "failed",
+ to_date: "2026-07-05T00:00:00Z",
+ updated_at: "2026-07-01T00:00:00Z",
+ ...overrides,
+});
+
+const dagRuns: Array<BackfillDagRunResponse> = [
+ {
+ backfill_id: 7,
+ dag_id: "example_dag",
+ dag_run_id: null,
+ dag_run_state: null,
+ exception_reason: "already exists",
+ id: 1,
+ logical_date: null,
+ partition_key: "partition-a",
+ sort_ordinal: 1,
+ },
+ {
+ backfill_id: 7,
+ dag_id: "example_dag",
+ dag_run_id: "scheduled__2026-07-02",
+ dag_run_state: "success",
+ exception_reason: null,
+ id: 2,
+ logical_date: null,
+ partition_key: "partition-b",
+ sort_ordinal: 2,
+ },
+];
+
+const renderBackfills = (initialEntry = "/dags/example_dag/backfills") =>
+ render(
+ <BaseWrapper>
+ <MemoryRouter initialEntries={[initialEntry]}>
+ <Routes>
+ <Route element={<Backfills />} path="/dags/:dagId/backfills" />
+ <Route element={<Backfills />}
path="/dags/:dagId/backfills/:backfillId" />
+ </Routes>
+ </MemoryRouter>
+ </BaseWrapper>,
+ );
+
+const expectGetBackfillQuery = (backfillId: number) => {
+ const [parameters, queryKey, options] = mocks.getBackfill.mock.lastCall as [
+ { backfillId: number },
+ undefined,
+ {
+ enabled: boolean;
+ refetchInterval: (query: { state: { data: BackfillResponse } }) =>
number | false;
+ },
+ ];
+
+ expect(parameters).toEqual({ backfillId });
+ expect(queryKey).toBeUndefined();
+ expect(options.enabled).toBe(true);
+ expect(typeof options.refetchInterval).toBe("function");
+
+ return options.refetchInterval;
+};
+
+const expectDagRunsQuery = (backfillId: number) => {
+ const [parameters, queryKey, options] =
mocks.listBackfillDagRuns.mock.lastCall as [
+ { backfillId: number; limit: number; offset: number },
+ undefined,
+ {
+ enabled: boolean;
+ refetchInterval: (query: {
+ state: { data: { backfill_dag_runs: Array<BackfillDagRunResponse> } };
+ }) => number | false;
+ },
+ ];
+
+ expect(parameters).toEqual({ backfillId, limit: 25, offset: 0 });
+ expect(queryKey).toBeUndefined();
+ expect(options.enabled).toBe(true);
+ expect(typeof options.refetchInterval).toBe("function");
+
+ return options.refetchInterval;
+};
+
+describe("Backfills", () => {
+ beforeEach(() => {
+ mocks.getBackfill.mockReset();
+ mocks.listBackfillDagRuns.mockReset();
+ mocks.listBackfills.mockReset();
+ });
+
+ it("opens a backfill's associated slots in a dialog", async () => {
+ const backfills = [
+ makeBackfill(),
+ makeBackfill({
+ completed_at: "2026-08-06T00:00:00Z",
+ from_date: "2026-08-01T00:00:00Z",
+ id: 8,
+ to_date: "2026-08-05T00:00:00Z",
+ }),
+ ];
+
+ mocks.getBackfill.mockImplementation(({ backfillId }: { backfillId: number
}) => ({
+ data: backfillId === 8 ? backfills[1] : backfills[0],
+ error: undefined,
+ isLoading: false,
+ }));
+ mocks.listBackfillDagRuns.mockReturnValue({
+ data: { backfill_dag_runs: dagRuns, total_entries: dagRuns.length },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+ mocks.listBackfills.mockReturnValue({
+ data: { backfills, total_entries: backfills.length },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+
+ renderBackfills();
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "View slots for
Backfill #7" }));
+
+ const dialog = await screen.findByRole("dialog");
+
+ expect(within(dialog).getByRole("heading", { name: "Backfill #7"
})).toBeInTheDocument();
+ expect(within(dialog).getByRole("heading", { name: "2 Slots"
})).toBeInTheDocument();
+ expect(within(dialog).getByRole("columnheader", { name: "Partition Key"
})).toBeInTheDocument();
+ expect(within(dialog).getByText("partition-a")).toBeInTheDocument();
+ expect(within(dialog).getByText("Already exists")).toBeInTheDocument();
+ expect(within(dialog).getByText("Success")).toBeInTheDocument();
+ expect(within(dialog).getByRole("link", { name: "scheduled__2026-07-02"
})).toHaveAttribute(
+ "href",
+ "/dags/example_dag/runs/scheduled__2026-07-02",
+ );
+ const getDagRunsRefetchInterval = expectDagRunsQuery(7);
+
+ expect(getDagRunsRefetchInterval({ state: { data: { backfill_dag_runs:
dagRuns } } })).toBe(5000);
+ const getBackfillRefetchInterval = expectGetBackfillQuery(7);
+
+ expect(getBackfillRefetchInterval({ state: { data: makeBackfill() }
})).toBe(5000);
+ expect(
+ getBackfillRefetchInterval({
+ state: { data: makeBackfill({ completed_at: "2026-08-06T00:00:00Z" })
},
+ }),
+ ).toBe(false);
+ expect(mocks.listBackfills).toHaveBeenCalledWith({
+ dagId: "example_dag",
+ limit: 25,
+ offset: 0,
+ });
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
+ await waitForElementToBeRemoved(dialog);
+ fireEvent.click(screen.getByText("2026-08-01T00:00:00Z"));
+ await screen.findByRole("dialog");
+
+ const getCompletedDagRunsRefetchInterval = expectDagRunsQuery(8);
+ const [, completedDagRun] = dagRuns;
+
+ if (completedDagRun === undefined) {
+ throw new Error("Expected a completed Dag run fixture");
+ }
+
+ expect(getCompletedDagRunsRefetchInterval({ state: { data: {
backfill_dag_runs: dagRuns } } })).toBe(
+ false,
+ );
+ expect(
+ getCompletedDagRunsRefetchInterval({
+ state: {
+ data: {
+ backfill_dag_runs: [
+ {
+ ...completedDagRun,
+ dag_run_state: "queued",
+ },
+ ],
+ },
+ },
+ }),
+ ).toBe(5000);
+ });
+
+ it("opens a linked backfill with logical dates and renders creation
reasons", async () => {
+ const backfill = makeBackfill({ id: 9 });
+ const logicalDate = "2026-07-03T00:00:00Z";
+ const reason = "future reason";
+
+ mocks.getBackfill.mockReturnValue({
+ data: backfill,
+ error: undefined,
+ isLoading: false,
+ });
+ mocks.listBackfillDagRuns.mockReturnValue({
+ data: {
+ backfill_dag_runs: [
+ {
+ ...dagRuns[0],
+ backfill_id: 9,
+ exception_reason: "in flight",
+ logical_date: logicalDate,
+ partition_key: null,
+ },
+ {
+ ...dagRuns[0],
+ backfill_id: 9,
+ exception_reason: "unknown",
+ id: 2,
+ logical_date: logicalDate,
+ partition_key: null,
+ sort_ordinal: 2,
+ },
+ {
+ ...dagRuns[0],
+ backfill_id: 9,
+ exception_reason: reason as
BackfillDagRunResponse["exception_reason"],
+ id: 3,
+ logical_date: logicalDate,
+ partition_key: null,
+ sort_ordinal: 3,
+ },
+ ],
+ total_entries: 3,
+ },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+ mocks.listBackfills.mockReturnValue({
+ data: { backfills: [], total_entries: 0 },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+
+ renderBackfills("/dags/example_dag/backfills/9");
+
+ const dialog = await screen.findByRole("dialog");
+
+ expect(within(dialog).getByRole("columnheader", { name: "Logical Date"
})).toBeInTheDocument();
+ expect(within(dialog).getAllByText(logicalDate)).toHaveLength(3);
+ expect(within(dialog).getByText("In flight")).toBeInTheDocument();
+ expect(within(dialog).getByText("Unknown")).toBeInTheDocument();
+ expect(within(dialog).getByText(reason)).toBeInTheDocument();
+ expectGetBackfillQuery(9);
+ });
+
+ it("requests the visible slot page and resets pagination after close", async
() => {
+ const backfill = makeBackfill();
+
+ mocks.getBackfill.mockReturnValue({
+ data: backfill,
+ error: undefined,
+ isLoading: false,
+ });
+ mocks.listBackfillDagRuns.mockReturnValue({
+ data: { backfill_dag_runs: dagRuns, total_entries: 26 },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+ mocks.listBackfills.mockReturnValue({
+ data: { backfills: [backfill], total_entries: 1 },
+ error: undefined,
+ isFetching: false,
+ isLoading: false,
+ });
+
+ renderBackfills("/dags/example_dag/backfills/7");
+
+ const dialog = await screen.findByRole("dialog");
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "next page" }));
+ await waitFor(() =>
+ expect(mocks.listBackfillDagRuns).toHaveBeenLastCalledWith(
+ {
+ backfillId: 7,
+ limit: 25,
+ offset: 25,
+ },
+ undefined,
+ expect.objectContaining({ enabled: true }),
+ ),
+ );
+
+ fireEvent.click(within(dialog).getByRole("button", { name: "Close" }));
+ await waitForElementToBeRemoved(dialog);
+ fireEvent.click(screen.getByRole("button", { name: "View slots for
Backfill #7" }));
+ await screen.findByRole("dialog");
+ await waitFor(() =>
+ expect(mocks.listBackfillDagRuns).toHaveBeenLastCalledWith(
+ {
+ backfillId: 7,
+ limit: 25,
+ offset: 0,
+ },
+ undefined,
+ expect.objectContaining({ enabled: true }),
+ ),
+ );
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
index c4ffbc8e20f..41009ffe2e6 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Backfills/Backfills.tsx
@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Heading, Text } from "@chakra-ui/react";
+import { Box, Button, Heading, Text } from "@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
+import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
-import { useParams } from "react-router-dom";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useBackfillServiceListBackfillsUi } from "openapi/queries";
import type { BackfillResponse } from "openapi/requests/types.gen";
@@ -29,13 +30,24 @@ import { ErrorAlert } from "src/components/ErrorAlert";
import Time from "src/components/Time";
import { getDuration } from "src/utils";
-const getColumns = (translate: (key: string) => string):
Array<ColumnDef<BackfillResponse>> => [
+import { BackfillDagRunsModal } from "./BackfillDagRunsModal";
+
+const getColumns = (
+ onSelectBackfill: (backfillId: number) => void,
+ translate: TFunction,
+): Array<ColumnDef<BackfillResponse>> => [
{
accessorKey: "date_from",
cell: ({ row }) => (
- <Text>
+ <Button
+ aria-label={translate("components:backfill.viewSlots", { id:
row.original.id })}
+ colorPalette="brand"
+ fontWeight="bold"
+ onClick={() => onSelectBackfill(row.original.id)}
+ variant="plain"
+ >
<Time datetime={row.original.from_date} />
- </Text>
+ </Button>
),
enableSorting: false,
header: translate("table.from"),
@@ -106,18 +118,40 @@ const getColumns = (translate: (key: string) => string):
Array<ColumnDef<Backfil
export const Backfills = () => {
const { t: translate } = useTranslation();
const { setTableURLState, tableURLState } = useTableURLState();
+ const location = useLocation();
+ const navigate = useNavigate();
const { pagination } = tableURLState;
- const { dagId = "" } = useParams();
-
+ const { backfillId, dagId = "" } = useParams();
+ const selectedBackfillId = Number(backfillId);
+ const hasSelectedBackfill = Number.isInteger(selectedBackfillId) &&
selectedBackfillId > 0;
const { data, error, isFetching, isLoading } =
useBackfillServiceListBackfillsUi({
dagId,
limit: pagination.pageSize,
offset: pagination.pageIndex * pagination.pageSize,
});
- const columns = getColumns(translate);
+ const onSelectBackfill = (id: number) => {
+ void Promise.resolve(
+ navigate({
+ pathname: `/dags/${dagId}/backfills/${id}`,
+ search: location.search,
+ }),
+ );
+ };
+ const onClose = () => {
+ void Promise.resolve(
+ navigate(
+ {
+ pathname: `/dags/${dagId}/backfills`,
+ search: location.search,
+ },
+ { replace: true },
+ ),
+ );
+ };
+ const columns = getColumns(onSelectBackfill, translate);
return (
<Box>
@@ -134,6 +168,12 @@ export const Backfills = () => {
onStateChange={setTableURLState}
total={data ? data.total_entries : 0}
/>
+ <BackfillDagRunsModal
+ backfillId={hasSelectedBackfill ? selectedBackfillId : undefined}
+ dagId={dagId}
+ onClose={onClose}
+ open={hasSelectedBackfill}
+ />
</Box>
);
};
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 26a982dd732..38a160e935a 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -193,6 +193,7 @@ export const routerConfig = [
// deep links alive by rendering the overview, where the route sync
opens the modal.
{ element: <Overview />, path: "required_actions" },
{ element: <Backfills />, path: "backfills" },
+ { element: <Backfills />, path: "backfills/:backfillId" },
{ element: <Events />, path: "events" },
{ element: <Code />, path: "code" },
{ element: <DagDetails />, path: "details" },
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
index c28843ccd8c..c351f471c59 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/BackfillPage.ts
@@ -292,10 +292,13 @@ export class BackfillPage extends BasePage {
for (let i = 0; i < rowCount; i++) {
const row = rows.nth(i);
const cells = row.locator("td");
- const fromCell = ((await cells.nth(fromIndex).textContent()) ??
"").slice(0, 10);
- const toCell = ((await cells.nth(toIndex).textContent()) ??
"").slice(0, 10);
+ const fromCell = (await
cells.nth(fromIndex).locator("time").getAttribute("datetime")) ?? "";
+ const toCell = (await
cells.nth(toIndex).locator("time").getAttribute("datetime")) ?? "";
- if (fromCell === expectedFrom.slice(0, 10) && toCell ===
expectedTo.slice(0, 10)) {
+ if (
+ fromCell.slice(0, 10) === expectedFrom.slice(0, 10) &&
+ toCell.slice(0, 10) === expectedTo.slice(0, 10)
+ ) {
foundRow = row;
foundColumnMap = columnMap;
@@ -336,8 +339,8 @@ export class BackfillPage extends BasePage {
const completedAtIndex = getColumnIndex(columnMap, "Completed at");
const [fromDate, toDate, reprocessBehavior, createdAt, completedAt] =
await Promise.all([
- cells.nth(fromIndex).textContent(),
- cells.nth(toIndex).textContent(),
+ cells.nth(fromIndex).locator("time").getAttribute("datetime"),
+ cells.nth(toIndex).locator("time").getAttribute("datetime"),
cells.nth(reprocessIndex).textContent(),
cells.nth(createdAtIndex).textContent(),
cells.nth(completedAtIndex).textContent(),