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(),

Reply via email to