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 7fbcfeea272 UI: Replace stacked Dag header banners with inline status 
(#74516)
7fbcfeea272 is described below

commit 7fbcfeea2728160e60849ebebd51081b477e1a8e
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Oct 19 12:37:15 2026 -0400

    UI: Replace stacked Dag header banners with inline status (#74516)
    
    Dag, Dag-run and task-instance headers had accumulated full-width banners 
that
    stack on each other and repeat across pages. Draining and the pending review
    indicator each had two separate implementations, so the same concept 
rendered
    differently depending on which page you were on.
    
    The approach follows review feedback on #74131: keep the short status inline
    and put the detail behind the control that reports it. Each status now sits
    where it can be read without crowding what it qualifies -- the deactivation
    marker ahead of the breadcrumb, the backfill notice beside the Dag's name, 
the
    retry reason among the task instance's own facts.
    
    Reparse was previously hidden on a Dag that had failed to parse, which is 
the
    state where it is most useful. A plugin that fails to load no longer 
replaces
    the page with an error.
    
    Co-authored-by: Claude <[email protected]>
---
 .../components/BackfillInProgressBadge.test.tsx    |  68 ++++++++++
 .../ui/src/components/BackfillInProgressBadge.tsx  |  63 +++++++++
 .../src/components/Banner/BackfillBanner.test.tsx  |  94 -------------
 .../ui/src/components/Banner/BackfillBanner.tsx    | 150 ---------------------
 .../src/components/Banner/DrainingBanner.test.tsx  |  88 ------------
 .../ui/src/components/Banner/DrainingBanner.tsx    |  68 ----------
 .../src/airflow/ui/src/components/Banner/index.tsx |  21 ---
 ...Banner.test.tsx => DagDeactivatedChip.test.tsx} |  35 +++--
 ...eactivatedBanner.tsx => DagDeactivatedChip.tsx} |  43 +++---
 .../ui/src/components/DagImportErrorModal.tsx      |  27 +++-
 .../ui/src/components/DrainingBadge.test.tsx       |  72 ++++++++++
 .../airflow/ui/src/components/DrainingBadge.tsx    |  59 +++++++-
 .../src/airflow/ui/src/components/HeaderCard.tsx   |   8 +-
 .../airflow/ui/src/components/NeedsReviewBadge.tsx |  18 +--
 .../ui/src/components/NeedsReviewButton.tsx        |  58 +++++---
 .../src/components/NeedsReviewIndicator.test.tsx   |  58 ++++++++
 .../ui/src/components/NeedsReviewIndicator.tsx     |  71 ++++++++++
 .../src/airflow/ui/src/components/StatusChip.tsx   |  67 +++++++++
 .../ui/src/layouts/Details/DetailsLayout.tsx       |  12 +-
 .../ui/src/pages/Dag/Backfills/Backfills.test.tsx  |  64 +++++++++
 .../ui/src/pages/Dag/Backfills/Backfills.tsx       |  97 +++++++++++--
 .../src/airflow/ui/src/pages/Dag/Header.tsx        |  12 +-
 .../src/airflow/ui/src/pages/DagsList/DagCard.tsx  |   2 +-
 .../ui/src/pages/DagsList/DagsListColumns.tsx      |   2 +-
 .../src/airflow/ui/src/pages/ReactPlugin.test.tsx  |  10 ++
 .../src/airflow/ui/src/pages/ReactPlugin.tsx       |   7 +-
 .../src/airflow/ui/src/pages/Run/Header.tsx        |   2 +-
 .../ui/src/pages/TaskInstance/Header.test.tsx      |  31 ++---
 .../airflow/ui/src/pages/TaskInstance/Header.tsx   |  45 ++++---
 .../ui/src/pages/TaskInstance/stateReason.ts       |   8 +-
 .../airflow/ui/tests/e2e/specs/backfill.spec.ts    |  12 +-
 31 files changed, 795 insertions(+), 577 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.test.tsx 
b/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.test.tsx
new file mode 100644
index 00000000000..1fe4a0839aa
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.test.tsx
@@ -0,0 +1,68 @@
+/*!
+ * 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/vitest";
+import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { BackfillInProgressBadge } from "./BackfillInProgressBadge";
+
+const { mockBackfills } = vi.hoisted(() => ({
+  mockBackfills: { current: [] as Array<{ completed_at: string | null }> },
+}));
+
+vi.mock("openapi/queries", () => ({
+  useBackfillServiceListBackfillsUi: () => ({ data: { backfills: 
mockBackfills.current } }),
+  useDagRunServiceGetDagRuns: () => ({ data: undefined }),
+  useDagServiceGetDagDetails: () => ({ data: undefined }),
+}));
+
+vi.mock("src/queries/useConfig", () => ({ useConfig: () => undefined }));
+
+const label = i18n.t("banner.backfillInProgress", { ns: "components" });
+
+afterEach(() => {
+  mockBackfills.current = [];
+});
+
+describe("BackfillInProgressBadge", () => {
+  it("links to the Backfills tab while a backfill has not finished", () => {
+    mockBackfills.current = [{ completed_at: null }];
+
+    render(<BackfillInProgressBadge dagId="example_dag" />, { wrapper: Wrapper 
});
+
+    expect(screen.getByRole("link", { name: label })).toHaveAttribute("href", 
"/dags/example_dag/backfills");
+  });
+
+  it("renders nothing once every backfill has completed", () => {
+    mockBackfills.current = [{ completed_at: "2024-08-22T19:00:00+00:00" }];
+
+    render(<BackfillInProgressBadge dagId="example_dag" />, { wrapper: Wrapper 
});
+
+    expect(screen.queryByRole("link", { name: label 
})).not.toBeInTheDocument();
+  });
+
+  it("renders nothing when the Dag has never been backfilled", () => {
+    render(<BackfillInProgressBadge dagId="example_dag" />, { wrapper: Wrapper 
});
+
+    expect(screen.queryByRole("link", { name: label 
})).not.toBeInTheDocument();
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.tsx 
b/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.tsx
new file mode 100644
index 00000000000..cb8208c25db
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/BackfillInProgressBadge.tsx
@@ -0,0 +1,63 @@
+/*!
+ * 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 { Badge } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { RiArrowGoBackFill } from "react-icons/ri";
+
+import { useBackfillServiceListBackfillsUi } from "openapi/queries";
+
+import { RouterLink } from "src/system-components";
+
+import { useAutoRefresh } from "src/utils";
+
+type Props = {
+  readonly dagId: string;
+};
+
+/** Reports a backfill that has not finished yet, linking to the Dag's 
Backfills tab. */
+export const BackfillInProgressBadge = ({ dagId }: Props) => {
+  const { t: translate } = useTranslation("components");
+  // Not keyed to the Dag, whose paused state stops auto-refresh: a backfill 
keeps running
+  // on a paused Dag, so the badge has to keep up with it either way.
+  const refetchInterval = useAutoRefresh({});
+
+  const { data } = useBackfillServiceListBackfillsUi({ active: true, dagId }, 
undefined, {
+    enabled: dagId !== "",
+    // Only poll while something is still running, so an idle Dag page does 
not tick against
+    // /ui/backfills forever.
+    refetchInterval: (query) =>
+      query.state.data?.backfills.some((backfill) => backfill.completed_at === 
null)
+        ? refetchInterval
+        : false,
+  });
+
+  // An active backfill may already be finished; only an incomplete one is in 
progress.
+  if (!(data?.backfills ?? []).some((backfill) => backfill.completed_at === 
null)) {
+    return undefined;
+  }
+
+  return (
+    <RouterLink to={`/dags/${dagId}/backfills`}>
+      <Badge colorPalette="info" cursor="pointer" variant="subtle">
+        <RiArrowGoBackFill size={12} />
+        {translate("banner.backfillInProgress")}
+      </Badge>
+    </RouterLink>
+  );
+};
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
deleted file mode 100644
index 9443e59c69a..00000000000
--- a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.test.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-/*!
- * 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/vitest";
-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: () => ({
-    i18n: { language: "en" },
-    // 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
deleted file mode 100644
index adc3bf6658f..00000000000
--- a/airflow-core/src/airflow/ui/src/components/Banner/BackfillBanner.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-/*!
- * 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 { Box, Button, HStack, Spacer, Text, type ButtonProps } from 
"@chakra-ui/react";
-import { useQueryClient } from "@tanstack/react-query";
-import { useTranslation } from "react-i18next";
-import { MdInfo, MdPause, MdPlayArrow, MdStop } from "react-icons/md";
-import { RiArrowGoBackFill } from "react-icons/ri";
-
-import {
-  useBackfillServiceCancelBackfill,
-  useBackfillServiceListBackfillsUi,
-  useBackfillServiceListBackfillsUiKey,
-  useBackfillServicePauseBackfill,
-  useBackfillServiceUnpauseBackfill,
-} from "openapi/queries";
-import type { BackfillResponse } from "openapi/requests/types.gen";
-
-import { RouterLink, Tooltip } from "src/system-components";
-
-import { useAutoRefresh } from "src/utils";
-
-import Time from "../Time";
-
-type Props = {
-  readonly dagId: string;
-};
-
-const buttonProps = {
-  _hover: { bg: "info.contrast", color: "info.muted" },
-  borderColor: "info.contrast",
-  color: "info.contrast",
-  rounded: "full",
-  size: "xs",
-  variant: "outline",
-} satisfies ButtonProps;
-
-const BackfillBanner = ({ dagId }: Props) => {
-  const { t: translate } = useTranslation("components");
-  const refetchInterval = useAutoRefresh({ dagId });
-
-  const { data, isLoading } = useBackfillServiceListBackfillsUi(
-    {
-      active: true,
-      dagId,
-    },
-    undefined,
-    {
-      refetchInterval: (query) =>
-        query.state.data?.backfills.some((bf: BackfillResponse) => 
bf.completed_at === null && !bf.is_paused)
-          ? refetchInterval
-          : false,
-    },
-  );
-  const [backfill] = data?.backfills.filter((bf: BackfillResponse) => 
bf.completed_at === null) ?? [];
-
-  const queryClient = useQueryClient();
-  const onSuccess = async () => {
-    await queryClient.invalidateQueries({
-      queryKey: [useBackfillServiceListBackfillsUiKey],
-    });
-  };
-
-  const { isPending: isPausePending, mutate: pauseMutate } = 
useBackfillServicePauseBackfill({ onSuccess });
-  const { isPending: isUnPausePending, mutate: unpauseMutate } = 
useBackfillServiceUnpauseBackfill({
-    onSuccess,
-  });
-  const { isPending: isStopPending, mutate: stopPending } = 
useBackfillServiceCancelBackfill({ onSuccess });
-
-  const togglePause = () => {
-    if (backfill === undefined) {
-      return;
-    }
-    if (backfill.is_paused) {
-      unpauseMutate({ backfillId: backfill.id });
-    } else {
-      pauseMutate({ backfillId: backfill.id });
-    }
-  };
-
-  const cancel = () => {
-    if (backfill === undefined) {
-      return;
-    }
-    stopPending({ backfillId: backfill.id });
-  };
-
-  if (isLoading || backfill === undefined) {
-    return undefined;
-  }
-
-  return (
-    <Box bg="info.solid" borderRadius="full" color="info.contrast" my="1" 
px="2" py="1">
-      <HStack alignItems="center" ml={3}>
-        <RiArrowGoBackFill />
-        <RouterLink color="inherit" fontWeight="medium" 
to={`/dags/${dagId}/backfills`}>
-          {translate("banner.backfillInProgress")}:
-        </RouterLink>
-        <Tooltip content={translate("backfill.schedulerPriorityHint")} 
showArrow>
-          <span>
-            <MdInfo />
-          </span>
-        </Tooltip>
-        <Text fontSize="sm">
-          {" "}
-          <Time datetime={backfill.from_date} /> - <Time 
datetime={backfill.to_date} />
-        </Text>
-
-        <Spacer flex="max-content" />
-        <Button
-          aria-label={backfill.is_paused ? translate("banner.unpause") : 
translate("banner.pause")}
-          loading={isPausePending || isUnPausePending}
-          onClick={() => {
-            togglePause();
-          }}
-          {...buttonProps}
-        >
-          {backfill.is_paused ? <MdPlayArrow /> : <MdPause />}
-        </Button>
-        <Button
-          aria-label={translate("banner.cancel")}
-          loading={isStopPending}
-          onClick={() => {
-            cancel();
-          }}
-          {...buttonProps}
-        >
-          <MdStop />
-        </Button>
-      </HStack>
-    </Box>
-  );
-};
-
-export default BackfillBanner;
diff --git 
a/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.test.tsx 
b/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.test.tsx
deleted file mode 100644
index 7ff94a40f53..00000000000
--- a/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-/*!
- * 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/vitest";
-import { fireEvent, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-import type { DagSchedulingState } from "openapi/requests/types.gen";
-
-import { Wrapper } from "src/utils/Wrapper";
-
-import DrainingBanner from "./DrainingBanner";
-
-const mocks = vi.hoisted(() => ({ getDag: vi.fn(), isPending: false, mutate: 
vi.fn() }));
-
-vi.mock("openapi/queries", () => ({
-  useDagServiceGetDag: mocks.getDag,
-}));
-
-vi.mock("src/queries/useTogglePause", () => ({
-  useTogglePause: () => ({ isPending: mocks.isPending, mutate: mocks.mutate }),
-}));
-
-const mockDag = (schedulingState: DagSchedulingState) => ({ scheduling_state: 
schedulingState });
-
-afterEach(() => {
-  mocks.getDag.mockReset();
-  mocks.mutate.mockReset();
-  mocks.isPending = false;
-});
-
-describe("DrainingBanner", () => {
-  it.each(["active", "paused"] as const)("renders nothing when 
scheduling_state is %s", (state) => {
-    mocks.getDag.mockReturnValue({ data: mockDag(state) });
-
-    const { container } = render(<DrainingBanner dagId="example_dag" />, { 
wrapper: Wrapper });
-
-    expect(container).toBeEmptyDOMElement();
-  });
-
-  it("renders the banner when the Dag is draining", () => {
-    mocks.getDag.mockReturnValue({ data: mockDag("draining") });
-
-    render(<DrainingBanner dagId="example_dag" />, { wrapper: Wrapper });
-
-    expect(screen.getByTestId("banner-cancel-drain")).toBeInTheDocument();
-    expect(screen.getByTestId("banner-pause-now")).toBeInTheDocument();
-  });
-
-  it("cancels draining", () => {
-    mocks.getDag.mockReturnValue({ data: mockDag("draining") });
-
-    render(<DrainingBanner dagId="example_dag" />, { wrapper: Wrapper });
-    fireEvent.click(screen.getByTestId("banner-cancel-drain"));
-
-    expect(mocks.mutate).toHaveBeenCalledWith({
-      dagId: "example_dag",
-      requestBody: { scheduling_state: "active" },
-    });
-  });
-
-  it("pauses now", () => {
-    mocks.getDag.mockReturnValue({ data: mockDag("draining") });
-
-    render(<DrainingBanner dagId="example_dag" />, { wrapper: Wrapper });
-    fireEvent.click(screen.getByTestId("banner-pause-now"));
-
-    expect(mocks.mutate).toHaveBeenCalledWith({
-      dagId: "example_dag",
-      requestBody: { scheduling_state: "paused" },
-    });
-  });
-});
diff --git 
a/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.tsx 
b/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.tsx
deleted file mode 100644
index 1d657be5729..00000000000
--- a/airflow-core/src/airflow/ui/src/components/Banner/DrainingBanner.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-/*!
- * 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 { Button, HStack, Spacer, Text } from "@chakra-ui/react";
-import { useTranslation } from "react-i18next";
-import { MdHourglassTop } from "react-icons/md";
-
-import { useDagServiceGetDag } from "openapi/queries";
-
-import { useTogglePause } from "src/queries/useTogglePause";
-
-type Props = {
-  readonly dagId: string;
-};
-
-const DrainingBanner = ({ dagId }: Props) => {
-  const { t: translate } = useTranslation("dags");
-  const { data: dag } = useDagServiceGetDag({ dagId });
-
-  const { isPending, mutate } = useTogglePause({ dagId });
-
-  if (dag?.scheduling_state !== "draining") {
-    return undefined;
-  }
-
-  return (
-    <HStack bg="bg.warning" color="fg.warning" px={3} py={1}>
-      <MdHourglassTop />
-      <Text>{translate("schedulingBanner.message")}</Text>
-      <Spacer />
-      <Button
-        data-testid="banner-cancel-drain"
-        loading={isPending}
-        onClick={() => mutate({ dagId, requestBody: { scheduling_state: 
"active" } })}
-        size="xs"
-        variant="outline"
-      >
-        {translate("schedulingActions.cancelDrain")}
-      </Button>
-      <Button
-        data-testid="banner-pause-now"
-        loading={isPending}
-        onClick={() => mutate({ dagId, requestBody: { scheduling_state: 
"paused" } })}
-        size="xs"
-        variant="outline"
-      >
-        {translate("schedulingActions.pauseNow")}
-      </Button>
-    </HStack>
-  );
-};
-
-export default DrainingBanner;
diff --git a/airflow-core/src/airflow/ui/src/components/Banner/index.tsx 
b/airflow-core/src/airflow/ui/src/components/Banner/index.tsx
deleted file mode 100644
index 7df175afcf4..00000000000
--- a/airflow-core/src/airflow/ui/src/components/Banner/index.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-/*!
- * 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.
- */
-
-export { default as BackfillBanner } from "./BackfillBanner";
-export { default as DrainingBanner } from "./DrainingBanner";
diff --git 
a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx 
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.test.tsx
similarity index 79%
rename from 
airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx
rename to airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.test.tsx
index 0d9c2b3bbe6..23d264188e3 100644
--- a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.test.tsx
@@ -25,7 +25,7 @@ import { Wrapper } from "src/utils/Wrapper";
 
 import dagLocale from "../../public/i18n/locales/en/dag.json";
 import dashboardLocale from "../../public/i18n/locales/en/dashboard.json";
-import { DagDeactivatedBanner } from "./DagDeactivatedBanner";
+import { DagDeactivatedChip } from "./DagDeactivatedChip";
 
 const { mockUseDagServiceGetDag, mockUseImportErrorServiceGetImportErrors } = 
vi.hoisted(() => ({
   mockUseDagServiceGetDag: vi.fn(),
@@ -70,7 +70,7 @@ const emptyImportErrorsQuery = {
   isPending: false,
 };
 
-describe("DagDeactivatedBanner", () => {
+describe("DagDeactivatedChip", () => {
   beforeEach(() => {
     i18n.addResourceBundle("en", "dag", dagLocale, true, true);
     i18n.addResourceBundle("en", "dashboard", dashboardLocale, true, true);
@@ -83,21 +83,23 @@ describe("DagDeactivatedBanner", () => {
 
     render(
       <Wrapper>
-        <DagDeactivatedBanner />
+        <DagDeactivatedChip />
       </Wrapper>,
     );
 
-    expect(screen.queryByText(i18n.t("header.status.deactivated", { ns: "dag" 
}))).not.toBeInTheDocument();
+    expect(
+      screen.queryByLabelText(i18n.t("header.status.deactivated", { ns: "dag" 
})),
+    ).not.toBeInTheDocument();
   });
 
   it("shows a deactivated banner when stale with no import error", () => {
     render(
       <Wrapper>
-        <DagDeactivatedBanner />
+        <DagDeactivatedChip />
       </Wrapper>,
     );
 
-    expect(screen.getByText(i18n.t("header.status.deactivated", { ns: "dag" 
}))).toBeInTheDocument();
+    expect(screen.getByLabelText(i18n.t("header.status.deactivated", { ns: 
"dag" }))).toBeInTheDocument();
     expect(
       screen.queryByRole("button", {
         name: i18n.t("importErrors.dagImportError", { count: 1, ns: 
"dashboard" }),
@@ -105,7 +107,7 @@ describe("DagDeactivatedBanner", () => {
     ).not.toBeInTheDocument();
   });
 
-  it("shows a deactivated banner with an import error button when the API 
returns a file-scoped error", async () => {
+  it("names the import error and opens the parse error on click", async () => {
     mockUseImportErrorServiceGetImportErrors.mockReturnValue({
       ...emptyImportErrorsQuery,
       data: {
@@ -124,20 +126,25 @@ describe("DagDeactivatedBanner", () => {
 
     render(
       <Wrapper>
-        <DagDeactivatedBanner />
+        <DagDeactivatedChip />
       </Wrapper>,
     );
 
-    expect(screen.getByText(i18n.t("header.status.deactivated", { ns: "dag" 
}))).toBeInTheDocument();
-
-    const importErrorButton = screen.getByRole("button", {
-      name: i18n.t("importErrors.dagImportError", { count: 1, ns: "dashboard" 
}),
+    // One element states the status and opens the detail, rather than a badge 
plus a button.
+    const chip = screen.getByRole("button", {
+      name: i18n.t("importErrors.dagImportError_one", { ns: "dashboard" }),
     });
 
-    expect(importErrorButton).toBeInTheDocument();
+    // A parse error is named as such, so it is distinguishable from a Dag that
+    // merely disappeared from its file.
+    expect(
+      screen.queryByLabelText(i18n.t("header.status.deactivated", { ns: "dag" 
})),
+    ).not.toBeInTheDocument();
+
+    expect(chip).toBeInTheDocument();
     expect(screen.queryByText(/SyntaxError: invalid 
syntax/u)).not.toBeInTheDocument();
 
-    fireEvent.click(importErrorButton);
+    fireEvent.click(chip);
 
     await waitFor(() => {
       expect(screen.getByText("stale_dag.py")).toBeInTheDocument();
diff --git 
a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx 
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.tsx
similarity index 66%
rename from airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx
rename to airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.tsx
index acaa52e973f..750db4cfb3b 100644
--- a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagDeactivatedChip.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Button, HStack, Text, useDisclosure } from "@chakra-ui/react";
+import { useDisclosure } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { LuFileWarning } from "react-icons/lu";
 import { useParams } from "react-router-dom";
@@ -24,8 +24,9 @@ import { useParams } from "react-router-dom";
 import { useDagServiceGetDag, useImportErrorServiceGetImportErrors } from 
"openapi/queries";
 
 import { DagImportErrorModal } from "./DagImportErrorModal";
+import { StatusChip } from "./StatusChip";
 
-export const DagDeactivatedBanner = () => {
+export const DagDeactivatedChip = () => {
   const { t: translate } = useTranslation(["dag", "dashboard"]);
   const { dagId = "" } = useParams();
   const { onClose, onOpen, open } = useDisclosure();
@@ -47,25 +48,23 @@ export const DagDeactivatedBanner = () => {
   const importError = data?.import_errors[0];
 
   return (
-    <HStack bg="bg.warning" color="fg.warning" justifyContent="space-between" 
px={3} py={1}>
-      <Text>{translate("header.status.deactivated")}</Text>
-      {importError ? (
-        <>
-          <Button
-            borderColor="fg.warning"
-            colorPalette="warning"
-            onClick={onOpen}
-            size="xs"
-            variant="outline"
-          >
-            <HStack gap={1}>
-              <LuFileWarning size={14} />
-              {translate("dashboard:importErrors.dagImportError", { count: 1 
})}
-            </HStack>
-          </Button>
-          <DagImportErrorModal importError={importError} onClose={onClose} 
open={open} />
-        </>
-      ) : undefined}
-    </HStack>
+    <>
+      <StatusChip
+        icon={<LuFileWarning />}
+        label={
+          importError === undefined
+            ? translate("dag:header.status.deactivated")
+            : translate("dashboard:importErrors.dagImportError_one")
+        }
+        // Inert without a parse error, since there is nothing to open.
+        onClick={importError === undefined ? undefined : onOpen}
+        // A Dag that merely disappeared from its file is not the same problem 
as one that
+        // failed to parse, so only the parse error shouts.
+        severity={importError === undefined ? "warning" : "error"}
+      />
+      {importError === undefined ? undefined : (
+        <DagImportErrorModal importError={importError} onClose={onClose} 
open={open} />
+      )}
+    </>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/components/DagImportErrorModal.tsx 
b/airflow-core/src/airflow/ui/src/components/DagImportErrorModal.tsx
index 08570db8056..06c10749461 100644
--- a/airflow-core/src/airflow/ui/src/components/DagImportErrorModal.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DagImportErrorModal.tsx
@@ -18,16 +18,20 @@
  */
 import { ClipboardRoot, HStack, Text } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
+import { AiOutlineFileSync } from "react-icons/ai";
 import { LuFileWarning } from "react-icons/lu";
 import { PiFilePy } from "react-icons/pi";
 
+import { useDagParsingServiceReparseDagFile } from "openapi/queries";
 import type { ImportErrorResponse } from "openapi/requests/types.gen";
 
-import { ClipboardIconButton } from "src/system-components";
+import { ClipboardIconButton, IconButton, toaster } from 
"src/system-components";
 
 import { ErrorModal } from "src/components/ErrorModal";
 import Time from "src/components/Time";
 
+import { createErrorToaster } from "src/utils";
+
 type Props = {
   readonly importError: ImportErrorResponse;
   readonly onClose: () => void;
@@ -35,7 +39,17 @@ type Props = {
 };
 
 export const DagImportErrorModal = ({ importError, onClose, open }: Props) => {
-  const { t: translate } = useTranslation(["dashboard", "components"]);
+  const { t: translate } = useTranslation(["dashboard", "components", "dag"]);
+
+  const { isPending, mutate } = useDagParsingServiceReparseDagFile({
+    onError: (error) => createErrorToaster(error, { titleKey: 
"dag:parse.toaster.error.title" }, translate),
+    onSuccess: () =>
+      toaster.create({
+        description: translate("dag:parse.toaster.success.description"),
+        title: translate("dag:parse.toaster.success.title"),
+        type: "success",
+      }),
+  });
 
   return (
     <ErrorModal
@@ -55,6 +69,15 @@ export const DagImportErrorModal = ({ importError, onClose, 
open }: Props) => {
         <ClipboardRoot value={importError.filename}>
           <ClipboardIconButton variant="outline" />
         </ClipboardRoot>
+        <IconButton
+          data-testid="reparse-import-error"
+          label={translate("components:reparseDag")}
+          loading={isPending}
+          onClick={() => mutate({ fileToken: importError.file_token })}
+          variant="outline"
+        >
+          <AiOutlineFileSync />
+        </IconButton>
       </HStack>
       <Text color="fg.muted" fontSize="sm" mb={2}>
         {translate("importErrors.timestamp")}
diff --git a/airflow-core/src/airflow/ui/src/components/DrainingBadge.test.tsx 
b/airflow-core/src/airflow/ui/src/components/DrainingBadge.test.tsx
new file mode 100644
index 00000000000..a9a47764b56
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DrainingBadge.test.tsx
@@ -0,0 +1,72 @@
+/*!
+ * 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/vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { DrainingBadge } from "./DrainingBadge";
+
+const mocks = vi.hoisted(() => ({ isPending: false, mutate: vi.fn() }));
+
+vi.mock("src/queries/useTogglePause", () => ({
+  useTogglePause: () => ({ isPending: mocks.isPending, mutate: mocks.mutate }),
+}));
+
+const openPopover = () => 
fireEvent.click(screen.getByTestId("draining-badge"));
+
+afterEach(() => {
+  mocks.mutate.mockReset();
+  mocks.isPending = false;
+});
+
+describe("DrainingBadge", () => {
+  it("renders the draining state", () => {
+    render(<DrainingBadge dagId="example_dag" />, { wrapper: Wrapper });
+
+    expect(screen.getByTestId("draining-badge")).toBeInTheDocument();
+  });
+
+  it("keeps the drain actions behind the popover", async () => {
+    render(<DrainingBadge dagId="example_dag" />, { wrapper: Wrapper });
+
+    
expect(screen.queryByTestId("banner-cancel-drain")).not.toBeInTheDocument();
+
+    openPopover();
+
+    await waitFor(() => 
expect(screen.getByTestId("banner-cancel-drain")).toBeInTheDocument());
+    expect(screen.getByTestId("banner-pause-now")).toBeInTheDocument();
+  });
+
+  it.each([
+    ["banner-cancel-drain", "active"],
+    ["banner-pause-now", "paused"],
+  ] as const)("%s sets scheduling_state to %s", async (testId, 
schedulingState) => {
+    render(<DrainingBadge dagId="example_dag" />, { wrapper: Wrapper });
+    openPopover();
+    await waitFor(() => 
expect(screen.getByTestId(testId)).toBeInTheDocument());
+    fireEvent.click(screen.getByTestId(testId));
+
+    expect(mocks.mutate).toHaveBeenCalledWith({
+      dagId: "example_dag",
+      requestBody: { scheduling_state: schedulingState },
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx 
b/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
index cafbc9c3347..8c936f1ff57 100644
--- a/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
@@ -17,24 +17,69 @@
  * under the License.
  */
 import type { BadgeProps } from "@chakra-ui/react";
+import { Button, HStack, Stack, Text } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { MdHourglassTop } from "react-icons/md";
 
-import { Tooltip } from "src/system-components";
+import { Popover } from "src/system-components";
+
+import { useTogglePause } from "src/queries/useTogglePause";
 
 import { StateBadge } from "./StateBadge";
 
-export const DrainingBadge = (props: BadgeProps) => {
+type Props = {
+  readonly dagId: string;
+} & BadgeProps;
+
+export const DrainingBadge = ({ dagId, ...props }: Props) => {
   const { t: translate } = useTranslation("dags");
+  const { isPending, mutate } = useTogglePause({ dagId });
 
   return (
-    <Tooltip content={translate("draining.badgeTooltip")}>
-      <span>
-        <StateBadge colorPalette="warning" data-testid="draining-badge" 
variant="subtle" {...props}>
+    <Popover.Root lazyMount unmountOnExit>
+      <Popover.Trigger asChild>
+        <StateBadge
+          as="button"
+          colorPalette="warning"
+          cursor="pointer"
+          data-testid="draining-badge"
+          variant="subtle"
+          {...props}
+        >
           <MdHourglassTop />
           {translate("draining.label")}
         </StateBadge>
-      </span>
-    </Tooltip>
+      </Popover.Trigger>
+      <Popover.Content maxW="340px" width="fit-content">
+        <Popover.Arrow />
+        <Popover.Body>
+          <Stack gap={2}>
+            <Text data-testid="draining-explanation" fontSize="sm">
+              {translate("schedulingBanner.message")}
+            </Text>
+            <HStack gap={2}>
+              <Button
+                data-testid="banner-cancel-drain"
+                loading={isPending}
+                onClick={() => mutate({ dagId, requestBody: { 
scheduling_state: "active" } })}
+                size="xs"
+                variant="outline"
+              >
+                {translate("schedulingActions.cancelDrain")}
+              </Button>
+              <Button
+                data-testid="banner-pause-now"
+                loading={isPending}
+                onClick={() => mutate({ dagId, requestBody: { 
scheduling_state: "paused" } })}
+                size="xs"
+                variant="outline"
+              >
+                {translate("schedulingActions.pauseNow")}
+              </Button>
+            </HStack>
+          </Stack>
+        </Popover.Body>
+      </Popover.Content>
+    </Popover.Root>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx 
b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
index 5a6dce6312a..25ec335f0b6 100644
--- a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
+++ b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
@@ -25,24 +25,23 @@ import type { TaskInstanceState } from 
"openapi/requests/types.gen";
 
 import { StateBadge } from "src/components/StateBadge";
 
-import { DagDeactivatedBanner } from "./DagDeactivatedBanner";
-
 type Props = {
   readonly actions?: ReactNode;
   readonly icon: ReactNode;
   readonly state?: TaskInstanceState | null;
   readonly stats: Array<{ key?: string; label: string; value: ReactNode | 
string }>;
+  /** Status for the object in view, rendered beside its name. */
+  readonly statusBadge?: ReactNode;
   readonly subTitle?: ReactNode | string;
   readonly title: ReactNode | string;
   readonly type: "asset" | "dag" | "dagBundle" | "dagRun" | "task" | 
"taskGroup" | "taskInstance";
 };
 
-export const HeaderCard = ({ actions, icon, state, stats, subTitle, title, 
type }: Props) => {
+export const HeaderCard = ({ actions, icon, state, stats, statusBadge, 
subTitle, title, type }: Props) => {
   const { t: translate } = useTranslation();
 
   return (
     <Box bg="bg.muted" borderRadius="md" data-testid="header-card" 
flexShrink={0} overflow="hidden" px={3}>
-      <DagDeactivatedBanner />
       <div>
         <Flex alignItems="center" flexWrap="wrap" 
justifyContent="space-between" my={2}>
           <Flex alignItems="center" flexWrap="wrap" gap={2}>
@@ -68,6 +67,7 @@ export const HeaderCard = ({ actions, icon, state, stats, 
subTitle, title, type
                 {title}
               </Heading>
             </div>
+            {statusBadge}
             {subTitle === undefined ? null : <Box 
fontSize="md">{subTitle}</Box>}
             {state === undefined ? undefined : (
               <StateBadge state={state}>{state ? 
translate(`common:states.${state}`) : undefined}</StateBadge>
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx 
b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
index 14da7a5c9de..59a6d49fc7a 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewBadge.tsx
@@ -18,24 +18,19 @@
  */
 import { Button, useDisclosure } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
-import { LuUserRoundPen } from "react-icons/lu";
 import { Link } from "react-router-dom";
 
 import type { HITLDetail } from "openapi/requests/types.gen";
 
-import { Tooltip } from "src/system-components";
-
 import { HITLReviewModal } from 
"src/components/HITLReview/HITLReviewModal.tsx";
-import { StateBadge } from "src/components/StateBadge";
-
-import { formatNumber } from "src/utils";
+import { NeedsReviewIndicator } from "src/components/NeedsReviewIndicator";
 
 type Props = {
   readonly pendingActions: Array<HITLDetail>;
 };
 
 export const NeedsReviewBadge = ({ pendingActions }: Props) => {
-  const { i18n, t: translate } = useTranslation("hitl");
+  const { t: translate } = useTranslation("hitl");
   const { onClose, onOpen, open } = useDisclosure();
 
   if (pendingActions.length === 0) {
@@ -44,14 +39,7 @@ export const NeedsReviewBadge = ({ pendingActions }: Props) 
=> {
 
   return (
     <>
-      <Tooltip content={translate("requiredActionCount", { count: 
pendingActions.length })}>
-        <Button data-testid="needs-review-badge" onClick={onOpen} 
variant="plain">
-          <StateBadge colorPalette="awaiting_input" fontSize="md" 
variant="solid">
-            <LuUserRoundPen />
-            {formatNumber(pendingActions.length, i18n.language)}
-          </StateBadge>
-        </Button>
-      </Tooltip>
+      <NeedsReviewIndicator count={pendingActions.length} onClick={onOpen} />
       <HITLReviewModal
         headerAction={
           <Button asChild size="sm" variant="outline">
diff --git a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx 
b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
index a462a6fc437..2bfc93d521a 100644
--- a/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewButton.tsx
@@ -28,6 +28,7 @@ import { useHITLReviewModalRouteSync } from 
"src/components/HITLReview/useHITLRe
 
 import { useAutoRefresh } from "src/utils/query";
 
+import { NeedsReviewIndicator } from "./NeedsReviewIndicator";
 import { StatsCard } from "./StatsCard";
 
 const usePendingHitl = ({
@@ -96,54 +97,70 @@ const useCompletedHitl = ({
   return { completedHitlData, isError, isLoading };
 };
 
-const NeedsReviewButtonCard = ({
+/** "card" is the stats-row presentation; "chip" is the inline header 
presentation. */
+type NeedsReviewVariant = "card" | "chip";
+
+const NeedsReviewContent = ({
   hitlTIsCount,
   isLoading,
   link,
   onClick,
+  variant,
 }: {
   readonly hitlTIsCount: number;
   readonly isLoading: boolean;
   readonly link?: string;
   readonly onClick?: () => void;
+  readonly variant: NeedsReviewVariant;
 }) => {
   const { i18n, t: translate } = useTranslation("hitl");
 
   const isRTL = i18n.dir() === "rtl";
 
-  return hitlTIsCount > 0 ? (
-    <Box maxW="250px">
-      <StatsCard
-        colorScheme="awaiting_input"
-        count={hitlTIsCount}
-        icon={<LuUserRoundPen />}
-        isLoading={isLoading}
-        isRTL={isRTL}
-        label={translate("requiredAction_other")}
-        link={link}
-        onClick={onClick}
-      />
-    </Box>
-  ) : undefined;
+  if (hitlTIsCount <= 0) {
+    return undefined;
+  }
+
+  if (variant === "card") {
+    return (
+      <Box maxW="250px">
+        <StatsCard
+          colorScheme="awaiting_input"
+          count={hitlTIsCount}
+          icon={<LuUserRoundPen />}
+          isLoading={isLoading}
+          isRTL={isRTL}
+          label={translate("requiredAction_other")}
+          link={link}
+          onClick={onClick}
+        />
+      </Box>
+    );
+  }
+
+  return <NeedsReviewIndicator count={hitlTIsCount} onClick={onClick} 
to={link} />;
 };
 
 export const NeedsReviewButton = ({
   dagId,
   runId,
   taskId,
+  variant = "card",
 }: {
   readonly dagId?: string;
   readonly runId?: string;
   readonly taskId?: string;
+  readonly variant?: NeedsReviewVariant;
 }) => {
   const { isLoading, pendingHitlData } = usePendingHitl({ dagId, runId, taskId 
});
   const hitlTIsCount = pendingHitlData?.total_entries ?? 0;
 
   return (
-    <NeedsReviewButtonCard
+    <NeedsReviewContent
       hitlTIsCount={hitlTIsCount}
       isLoading={isLoading}
       link="/required_actions?response_received=false"
+      variant={variant}
     />
   );
 };
@@ -151,9 +168,11 @@ export const NeedsReviewButton = ({
 export const NeedsReviewButtonWithModal = ({
   dagId,
   runId,
+  variant = "card",
 }: {
   readonly dagId?: string;
   readonly runId?: string;
+  readonly variant?: NeedsReviewVariant;
 }) => {
   const { onClose, onOpen, open } = useDisclosure();
   const { onCloseHITLReview } = useHITLReviewModalRouteSync({
@@ -175,7 +194,12 @@ export const NeedsReviewButtonWithModal = ({
 
   return (
     <>
-      <NeedsReviewButtonCard hitlTIsCount={hitlTIsCount} isLoading={isLoading} 
onClick={onOpen} />
+      <NeedsReviewContent
+        hitlTIsCount={hitlTIsCount}
+        isLoading={isLoading}
+        onClick={onOpen}
+        variant={variant}
+      />
       <HITLReviewModal
         completedHitl={{
           data: completedHitlData?.hitl_details ?? [],
diff --git 
a/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.test.tsx 
b/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.test.tsx
new file mode 100644
index 00000000000..42d924bddf7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.test.tsx
@@ -0,0 +1,58 @@
+/*!
+ * 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/vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { NeedsReviewIndicator } from "./NeedsReviewIndicator";
+
+const label = (count: number) => i18n.t("requiredActionCount", { count, ns: 
"hitl" });
+
+describe("NeedsReviewIndicator", () => {
+  it("renders nothing when there is nothing to review", () => {
+    render(<NeedsReviewIndicator count={0} onClick={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.queryByTestId("needs-review-badge")).not.toBeInTheDocument();
+  });
+
+  it("shows the count and names itself for assistive tech", () => {
+    render(<NeedsReviewIndicator count={3} onClick={vi.fn()} />, { wrapper: 
Wrapper });
+
+    expect(screen.getByTestId("needs-review-badge")).toHaveTextContent("3");
+    expect(screen.getByRole("button", { name: label(3) })).toBeInTheDocument();
+  });
+
+  it("opens the review modal when given a handler", () => {
+    const onClick = vi.fn();
+
+    render(<NeedsReviewIndicator count={1} onClick={onClick} />, { wrapper: 
Wrapper });
+    fireEvent.click(screen.getByTestId("needs-review-badge"));
+
+    expect(onClick).toHaveBeenCalledTimes(1);
+  });
+
+  it("navigates instead when given a destination", () => {
+    render(<NeedsReviewIndicator count={1} to="/required_actions" />, { 
wrapper: Wrapper });
+
+    expect(screen.getByRole("link", { name: label(1) 
})).toHaveAttribute("href", "/required_actions");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.tsx 
b/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.tsx
new file mode 100644
index 00000000000..ff009b39302
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NeedsReviewIndicator.tsx
@@ -0,0 +1,71 @@
+/*!
+ * 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 { Button } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { LuUserRoundPen } from "react-icons/lu";
+
+import { RouterLink, Tooltip } from "src/system-components";
+
+import { StateBadge } from "src/components/StateBadge";
+
+import { formatNumber } from "src/utils";
+
+type Props = {
+  readonly count: number;
+  /** Opens the review modal. Omit when `to` navigates to the review page 
instead. */
+  readonly onClick?: () => void;
+  readonly to?: string;
+};
+
+/**
+ * The pending-review indicator, shared by every surface that reports one.
+ *
+ * Renders the `awaiting_input` StateBadge so it matches the state a parked 
task shows,
+ * and carries only the icon and count — the full wording lives in the 
tooltip, which
+ * also names the control for assistive tech.
+ */
+export const NeedsReviewIndicator = ({ count, onClick, to }: Props) => {
+  const { i18n, t: translate } = useTranslation("hitl");
+
+  if (count <= 0) {
+    return undefined;
+  }
+
+  const label = translate("requiredActionCount", { count });
+  const badge = (
+    <StateBadge colorPalette="awaiting_input" fontSize="md" variant="solid">
+      <LuUserRoundPen />
+      {formatNumber(count, i18n.language)}
+    </StateBadge>
+  );
+
+  return (
+    <Tooltip content={label}>
+      {to === undefined ? (
+        <Button aria-label={label} data-testid="needs-review-badge" 
onClick={onClick} variant="plain">
+          {badge}
+        </Button>
+      ) : (
+        <Button aria-label={label} asChild data-testid="needs-review-badge" 
variant="plain">
+          <RouterLink to={to}>{badge}</RouterLink>
+        </Button>
+      )}
+    </Tooltip>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/components/StatusChip.tsx 
b/airflow-core/src/airflow/ui/src/components/StatusChip.tsx
new file mode 100644
index 00000000000..557a5da210a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/StatusChip.tsx
@@ -0,0 +1,67 @@
+/*!
+ * 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 type { ReactNode } from "react";
+
+import { Badge } from "@chakra-ui/react";
+
+import { IconButton, Tooltip } from "src/system-components";
+
+/** Highest severity first; callers render chips in this order. */
+export type StatusChipSeverity = "error" | "info" | "warning";
+
+type Props = {
+  readonly icon: ReactNode;
+  /** Names the chip for its tooltip and for assistive tech, since nothing is 
written on it. */
+  readonly label: string;
+  /** Opens the chip's detail. Omit for a chip that only states status. */
+  readonly onClick?: () => void;
+  readonly severity: StatusChipSeverity;
+};
+
+/**
+ * A compact status marker. Icon only: these sit in tight places, where a 
worded chip pushes
+ * everything else around, so the wording lives in the tooltip.
+ *
+ * One that opens something is a real icon button, so it carries button 
semantics and the same
+ * hit area and hover as the actions beside it; one that only reports is an 
inert badge.
+ */
+export const StatusChip = ({ icon, label, onClick, severity }: Props) =>
+  onClick === undefined ? (
+    // Portalled, like IconButton's own: these sit in containers that clip 
what overflows them.
+    // Sized to match the button form so the two read as the same marker at 
different severities.
+    <Tooltip content={label} portalled>
+      <Badge
+        alignItems="center"
+        aria-label={label}
+        boxSize={8}
+        colorPalette={severity}
+        display="inline-flex"
+        fontSize="md"
+        justifyContent="center"
+        px={0}
+        variant="subtle"
+      >
+        {icon}
+      </Badge>
+    </Tooltip>
+  ) : (
+    <IconButton colorPalette={severity} label={label} onClick={onClick} 
size="sm" variant="solid">
+      {icon}
+    </IconButton>
+  );
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 60d9a64f53c..2672b9f1c6a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -38,9 +38,8 @@ import type { DagRunState, DagRunType } from 
"openapi/requests/types.gen";
 
 import { IconButton, ProgressBar, Toaster } from "src/system-components";
 
-import BackfillBanner from "src/components/Banner/BackfillBanner";
-import DrainingBanner from "src/components/Banner/DrainingBanner";
 import { countDagWarnings, DAGWarningsModal } from 
"src/components/DAGWarningsModal";
+import { DagDeactivatedChip } from "src/components/DagDeactivatedChip";
 import { TogglePause } from "src/components/TogglePause";
 import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
 
@@ -240,7 +239,12 @@ export const DetailsLayout = ({ children, error, 
isLoading, outletContext, tabs
     <GroupsProvider dagId={dagId}>
       <Box display="flex" flex={1} flexDirection="column" minH={0} minW={{ 
base: "1280px", md: "auto" }}>
         <HStack justifyContent="space-between" mb={3} mx={-3} px={3}>
-          <DagBreadcrumb />
+          <HStack gap={2}>
+            {/* Ahead of the trail rather than inside a level: it speaks for 
the Dag as a whole,
+              and a level is too tight to hold it without crowding what it 
names. */}
+            <DagDeactivatedChip />
+            <DagBreadcrumb />
+          </HStack>
           <Flex gap={2}>
             {dag === undefined ? undefined : (
               <>
@@ -268,8 +272,6 @@ export const DetailsLayout = ({ children, error, isLoading, 
outletContext, tabs
           </Flex>
         </HStack>
         <Toaster />
-        <DrainingBanner dagId={dagId} />
-        <BackfillBanner dagId={dagId} />
         <Box flex={1} minH={0}>
           {isRightPanelCollapsed ? (
             <IconButton
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
index 4bb7fc737d3..22d8419c714 100644
--- 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
@@ -37,16 +37,23 @@ import { BaseWrapper } from "src/utils/Wrapper";
 import { Backfills } from "./Backfills";
 
 const mocks = vi.hoisted(() => ({
+  cancelMutate: vi.fn(),
   getBackfill: vi.fn(),
   listBackfillDagRuns: vi.fn(),
   listBackfills: vi.fn(),
   listTeams: vi.fn(),
+  pauseMutate: vi.fn(),
+  unpauseMutate: vi.fn(),
 }));
 
 vi.mock("openapi/queries", () => ({
+  useBackfillServiceCancelBackfill: () => ({ isPending: false, mutate: 
mocks.cancelMutate }),
   useBackfillServiceGetBackfill: mocks.getBackfill,
   useBackfillServiceListBackfillDagRuns: mocks.listBackfillDagRuns,
   useBackfillServiceListBackfillsUi: mocks.listBackfills,
+  useBackfillServiceListBackfillsUiKey: "listBackfillsUi",
+  useBackfillServicePauseBackfill: () => ({ isPending: false, mutate: 
mocks.pauseMutate }),
+  useBackfillServiceUnpauseBackfill: () => ({ isPending: false, mutate: 
mocks.unpauseMutate }),
   useTeamsServiceListTeams: mocks.listTeams,
 }));
 
@@ -185,6 +192,12 @@ describe("Backfills filters", () => {
     mocks.listBackfillDagRuns.mockReset();
     mocks.listBackfills.mockReset();
     mocks.listTeams.mockReset();
+    mocks.cancelMutate.mockReset();
+    mocks.pauseMutate.mockReset();
+    mocks.unpauseMutate.mockReset();
+    // Benign defaults; tests that care override them.
+    mocks.getBackfill.mockReturnValue({ data: undefined, error: undefined, 
isLoading: false });
+    mocks.listBackfillDagRuns.mockReturnValue({ data: undefined, error: 
undefined, isLoading: false });
     mocks.listTeams.mockReturnValue({
       data: { teams: [], total_entries: 0 },
       error: undefined,
@@ -192,6 +205,57 @@ describe("Backfills filters", () => {
     });
   });
 
+  it.each([
+    [false, "components:banner.pause", "pauseMutate"],
+    [true, "components:banner.unpause", "unpauseMutate"],
+  ] as const)("an in-progress backfill toggles pause (is_paused=%s)", async 
(isPaused, label, mutateKey) => {
+    mocks.listBackfills.mockReturnValue({
+      data: { backfills: [makeBackfill({ is_paused: isPaused })], 
total_entries: 1 },
+      error: undefined,
+      isFetching: false,
+      isLoading: false,
+    });
+
+    renderBackfills();
+
+    fireEvent.click(await screen.findByRole("button", { name: label }));
+
+    expect(mocks[mutateKey]).toHaveBeenCalledWith({ backfillId: 7 });
+  });
+
+  it("an in-progress backfill can be cancelled", async () => {
+    mocks.listBackfills.mockReturnValue({
+      data: { backfills: [makeBackfill()], total_entries: 1 },
+      error: undefined,
+      isFetching: false,
+      isLoading: false,
+    });
+
+    renderBackfills();
+
+    fireEvent.click(await screen.findByRole("button", { name: 
"components:banner.cancel" }));
+
+    expect(mocks.cancelMutate).toHaveBeenCalledWith({ backfillId: 7 });
+  });
+
+  it("reports progress in place of a completion time, and hides the actions 
once complete", async () => {
+    mocks.listBackfills.mockReturnValue({
+      data: {
+        backfills: [makeBackfill(), makeBackfill({ completed_at: 
"2026-07-06T00:00:00Z", id: 8 })],
+        total_entries: 2,
+      },
+      error: undefined,
+      isFetching: false,
+      isLoading: false,
+    });
+
+    renderBackfills();
+
+    // Exactly one row is still running, so only it gets a progress badge and 
actions.
+    expect(await 
screen.findByText("components:banner.backfillInProgress")).toBeInTheDocument();
+    expect(screen.getAllByRole("button", { name: "components:banner.cancel" 
})).toHaveLength(1);
+  });
+
   it("opens a backfill's associated slots in a dialog", async () => {
     const backfills = [
       makeBackfill(),
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 3ba435b104e..0cd6772d705 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,15 +16,25 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Button, Text } from "@chakra-ui/react";
+import { Badge, Button, HStack, Text } from "@chakra-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
 import type { ColumnDef } from "@tanstack/react-table";
 import type { TFunction } from "i18next";
 import { useTranslation } from "react-i18next";
+import { MdPause, MdPlayArrow, MdStop } from "react-icons/md";
 import { useLocation, useNavigate, useParams, useSearchParams } from 
"react-router-dom";
 
-import { useBackfillServiceListBackfillsUi } from "openapi/queries";
+import {
+  useBackfillServiceCancelBackfill,
+  useBackfillServiceListBackfillsUi,
+  useBackfillServiceListBackfillsUiKey,
+  useBackfillServicePauseBackfill,
+  useBackfillServiceUnpauseBackfill,
+} from "openapi/queries";
 import type { BackfillResponse } from "openapi/requests/types.gen";
 
+import { IconButton } from "src/system-components";
+
 import { DataTable } from "src/components/DataTable";
 import type { DataTableFeatures } from "src/components/DataTable/features";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
@@ -55,13 +65,19 @@ const {
 }: SearchParamsKeysType = SearchParamsKeys;
 
 type ColumnProps = {
+  readonly isActionPending: boolean;
+  readonly onCancel: (backfillId: number) => void;
   readonly onSelectBackfill: (backfillId: number) => void;
+  readonly onTogglePause: (backfill: BackfillResponse) => void;
   readonly translate: TFunction;
 } & Pick<DurationFormat, "formatElapsed">;
 
 const getColumns = ({
   formatElapsed,
+  isActionPending,
+  onCancel,
   onSelectBackfill,
+  onTogglePause,
   translate,
 }: ColumnProps): Array<ColumnDef<DataTableFeatures, BackfillResponse>> => [
   {
@@ -116,11 +132,18 @@ const getColumns = ({
   },
   {
     accessorKey: "completed_at",
-    cell: ({ row }) => (
-      <Text>
-        <Time datetime={row.original.completed_at} />
-      </Text>
-    ),
+    cell: ({ row }) =>
+      row.original.completed_at === null ? (
+        <Badge colorPalette="info" variant="subtle">
+          {row.original.is_paused
+            ? translate("dags:filters.paused.paused")
+            : translate("components:banner.backfillInProgress")}
+        </Badge>
+      ) : (
+        <Text>
+          <Time datetime={row.original.completed_at} />
+        </Text>
+      ),
     enableSorting: false,
     header: translate("table.completedAt"),
   },
@@ -141,10 +164,44 @@ const getColumns = ({
     enableSorting: false,
     header: translate("table.maxActiveRuns"),
   },
+  {
+    accessorKey: "actions",
+    cell: ({ row }) =>
+      row.original.completed_at === null ? (
+        <HStack gap={1}>
+          <IconButton
+            colorPalette="info"
+            label={
+              row.original.is_paused
+                ? translate("components:banner.unpause")
+                : translate("components:banner.pause")
+            }
+            loading={isActionPending}
+            onClick={() => onTogglePause(row.original)}
+            size="xs"
+            variant="outline"
+          >
+            {row.original.is_paused ? <MdPlayArrow /> : <MdPause />}
+          </IconButton>
+          <IconButton
+            colorPalette="info"
+            label={translate("components:banner.cancel")}
+            loading={isActionPending}
+            onClick={() => onCancel(row.original.id)}
+            size="xs"
+            variant="outline"
+          >
+            <MdStop />
+          </IconButton>
+        </HStack>
+      ) : undefined,
+    enableSorting: false,
+    header: "",
+  },
 ];
 
 export const Backfills = () => {
-  const { t: translate } = useTranslation(["common", "components"]);
+  const { t: translate } = useTranslation(["common", "components", "dags"]);
   const { formatElapsed } = useDurationFormat();
   const { setTableURLState, tableURLState } = useTableURLState();
   const location = useLocation();
@@ -213,7 +270,29 @@ export const Backfills = () => {
       ),
     );
   };
-  const columns = getColumns({ formatElapsed, onSelectBackfill, translate });
+  const queryClient = useQueryClient();
+  const onSuccess = async () => {
+    await queryClient.invalidateQueries({ queryKey: 
[useBackfillServiceListBackfillsUiKey] });
+  };
+  const { isPending: isPausePending, mutate: pauseMutate } = 
useBackfillServicePauseBackfill({ onSuccess });
+  const { isPending: isUnpausePending, mutate: unpauseMutate } = 
useBackfillServiceUnpauseBackfill({
+    onSuccess,
+  });
+  const { isPending: isCancelPending, mutate: cancelMutate } = 
useBackfillServiceCancelBackfill({
+    onSuccess,
+  });
+
+  const columns = getColumns({
+    formatElapsed,
+    isActionPending: isPausePending || isUnpausePending || isCancelPending,
+    onCancel: (id) => cancelMutate({ backfillId: id }),
+    onSelectBackfill,
+    onTogglePause: (backfill) =>
+      backfill.is_paused
+        ? unpauseMutate({ backfillId: backfill.id })
+        : pauseMutate({ backfillId: backfill.id }),
+    translate,
+  });
 
   return (
     <>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
index fb4e37751f7..a799a0cbb46 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -24,6 +24,7 @@ import type { DAGDetailsResponse, DagRunState } from 
"openapi/requests/types.gen
 
 import { RouterLink } from "src/system-components";
 
+import { BackfillInProgressBadge } from 
"src/components/BackfillInProgressBadge";
 import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton";
 import { FavoriteDagButton } from 
"src/components/DagActions/FavoriteDagButton";
 import { ParseDagButton } from "src/components/DagActions/ParseDagButton";
@@ -73,7 +74,7 @@ export const Header = ({
         {
           label: translate("dagDetails.nextRun"),
           value: dag?.is_paused ? undefined : dag?.scheduling_state === 
"draining" ? (
-            <DrainingBadge />
+            <DrainingBadge dagId={dag.dag_id} />
           ) : Boolean(dag?.next_dagrun_run_after) ? (
             <DagRunInfo
               logicalDate={dag?.next_dagrun_logical_date}
@@ -150,7 +151,7 @@ export const Header = ({
         dag === undefined ? undefined : (
           <>
             <DeadlineAlertsBadge dagId={dag.dag_id} />
-            <NeedsReviewButtonWithModal dagId={dag.dag_id} />
+            <NeedsReviewButtonWithModal dagId={dag.dag_id} variant="chip" />
             {dag.doc_md === null ? undefined : (
               <DisplayMarkdownButton
                 bg="bg"
@@ -162,9 +163,9 @@ export const Header = ({
               />
             )}
             <FavoriteDagButton bg="bg" dagId={dag.dag_id} 
isFavorite={dag.is_favorite} variant="outline" />
-            {isStale ? undefined : (
-              <ParseDagButton bg="bg" dagId={dag.dag_id} 
fileToken={dag.file_token} variant="outline" />
-            )}
+            {/* Deliberately shown while stale: a Dag that failed to parse or 
vanished from
+                its file is exactly when reparsing is worth offering. */}
+            <ParseDagButton bg="bg" dagId={dag.dag_id} 
fileToken={dag.file_token} variant="outline" />
             <DeleteDagButton
               bg="bg"
               dagDisplayName={dag.dag_display_name}
@@ -176,6 +177,7 @@ export const Header = ({
       }
       icon={<DagIcon />}
       stats={stats}
+      statusBadge={<BackfillInProgressBadge dagId={dagId ?? ""} />}
       title={dag?.dag_display_name ?? dagId}
       type="dag"
     />
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
index 437e54ce081..99ac39fd633 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagCard.tsx
@@ -134,7 +134,7 @@ export const DagCard = ({
         <GridItem gridColumn={3} gridRow={1}>
           <Stat data-testid="next-run" label={translate("dagDetails.nextRun")}>
             {dag.is_paused ? undefined : dag.scheduling_state === "draining" ? 
(
-              <DrainingBadge />
+              <DrainingBadge dagId={dag.dag_id} />
             ) : Boolean(dag.next_dagrun_run_after) ? (
               <DagRunInfo
                 logicalDate={dag.next_dagrun_logical_date}
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsListColumns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsListColumns.tsx
index ebd40300c15..2ea3d478b85 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsListColumns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsListColumns.tsx
@@ -90,7 +90,7 @@ const ScheduleCell = ({ row: { original } }: CellProps) => (
 
 const NextRunCell = ({ row: { original } }: CellProps) =>
   original.is_paused ? undefined : original.scheduling_state === "draining" ? (
-    <DrainingBadge />
+    <DrainingBadge dagId={original.dag_id} />
   ) : Boolean(original.next_dagrun_run_after) ? (
     <Box whiteSpace="nowrap">
       <DagRunInfo
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
index adbf65ee8ce..ab12e73ce37 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.test.tsx
@@ -177,6 +177,16 @@ describe("loadPlugin", () => {
     }
   });
 
+  it("renders nothing when the bundle cannot be fetched", async () => {
+    vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+    const { default: Component } = await loadPlugin(pluginA, () => 
Promise.reject(new Error("network down")));
+    const { container } = render(<Component />, { wrapper: Wrapper });
+
+    // An error page here would bury the page the plugin was only decorating.
+    expect(container).toBeEmptyDOMElement();
+  });
+
   it("does not let a malformed bundle inherit a previously-loaded plugin's 
component", async () => {
     // A well-formed bundle sets globalThis.AirflowPlugin on import; a 
malformed one sets nothing.
     await loadPlugin(pluginA, () => {
diff --git a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx 
b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
index 3d7b55a2d92..cf13bb3d250 100644
--- a/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ReactPlugin.tsx
@@ -37,8 +37,6 @@ import type {
 
 import { useTaskInstanceCoordinates } from 
"src/hooks/useTaskInstanceCoordinates";
 
-import { ErrorPage } from "./Error";
-
 export type PluginProps = {
   asset?: AssetResponse;
   assetId?: string;
@@ -84,7 +82,10 @@ export const loadPlugin = (
       // eslint-disable-next-line no-console
       console.error("Component failed to load:", error);
 
-      return { default: ErrorPage };
+      // A plugin that will not load is a deployment problem, not this page's. 
Rendering an error
+      // page in its slot would bury whatever the user actually came for, so 
the slot stays empty
+      // and the console carries the reason.
+      return { default: () => null };
     });
 };
 
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index 7c281c4ad92..0facc8aed23 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -65,7 +65,7 @@ export const Header = ({ dagRun }: { readonly dagRun: 
DAGRunResponse }) => {
       <HeaderCard
         actions={
           <>
-            <NeedsReviewButtonWithModal dagId={dagId} runId={dagRunId} />
+            <NeedsReviewButtonWithModal dagId={dagId} runId={dagRunId} 
variant="chip" />
             <ClearRunButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
             <MarkRunAsButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
             <DeleteRunButton bg="bg" dagRun={dagRun} variant="outline" />
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
index 27302696b11..6a638ee95ea 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
@@ -147,7 +147,7 @@ describe("Header state reason banner", () => {
   it("does not render when there is no reason", () => {
     renderHeader({ state: "failed", state_reason: null });
 
-    expect(screen.queryByTestId("state-reason-alert")).not.toBeInTheDocument();
+    expect(screen.queryByTestId("state-reason-stat")).not.toBeInTheDocument();
   });
 
   // Cleared only once the task next reaches RUNNING, so these states still 
carry a stale reason.
@@ -156,7 +156,7 @@ describe("Header state reason banner", () => {
     (state) => {
       renderHeader({ state, state_reason: "auth error, do not retry" });
 
-      
expect(screen.queryByTestId("state-reason-alert")).not.toBeInTheDocument();
+      
expect(screen.queryByTestId("state-reason-stat")).not.toBeInTheDocument();
     },
   );
 
@@ -164,26 +164,15 @@ describe("Header state reason banner", () => {
     { maxTries: 2, state: "failed", titleKey: "failed", totalTries: 3, 
tryNumber: 3 },
     // Differing numbers are what make a swapped or off-by-one interpolation 
visible.
     { maxTries: 3, state: "up_for_retry", titleKey: "upForRetry", totalTries: 
4, tryNumber: 2 },
-  ] as const)(
-    "titles the banner for a $state task",
-    ({ maxTries, state, titleKey, totalTries, tryNumber }) => {
-      renderHeader({ max_tries: maxTries, state, state_reason: "auth error", 
try_number: tryNumber });
+  ] as const)("labels the stat for a $state task", ({ maxTries, state, 
titleKey, totalTries, tryNumber }) => {
+    renderHeader({ max_tries: maxTries, state, state_reason: "auth error", 
try_number: tryNumber });
 
-      expect(screen.getByTestId("state-reason-alert")).toHaveTextContent(
+    // The summary is the stat's label and the reason its value, so they are 
separate elements.
+    expect(
+      screen.getByText(
         i18n.t(`common:taskInstance.stateReasonSummary.${titleKey}`, { 
totalTries, tryNumber }),
-      );
-      expect(screen.getByTestId("state-reason-alert")).toHaveTextContent("auth 
error");
-    },
-  );
-
-  // Chakra puts `status` in a generated class, so "not identical" is all that 
can be asserted.
-  it("styles a failed banner differently from an up_for_retry one", () => {
-    const { unmount } = renderHeader({ state: "failed", state_reason: "auth 
error" });
-    const failedClass = screen.getByTestId("state-reason-alert").className;
-
-    unmount();
-    renderHeader({ state: "up_for_retry", state_reason: "auth error" });
-
-    
expect(screen.getByTestId("state-reason-alert").className).not.toBe(failedClass);
+      ),
+    ).toBeInTheDocument();
+    expect(screen.getByTestId("state-reason-stat")).toHaveTextContent("auth 
error");
   });
 });
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index dbf7381a925..5bf5e2769ed 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -18,13 +18,13 @@
  */
 import { useState } from "react";
 
-import { Box } from "@chakra-ui/react";
+import { Box, Text } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { MdOutlineTask } from "react-icons/md";
 
 import type { TaskInstanceHistoryResponse, TaskInstanceResponse } from 
"openapi/requests/types.gen";
 
-import { Alert } from "src/system-components";
+import { Tooltip } from "src/system-components";
 
 import { ClearTaskInstanceButton } from "src/components/Clear";
 import ClearTaskInstanceDialog from 
"src/components/Clear/TaskInstance/ClearTaskInstanceDialog";
@@ -80,7 +80,32 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
     (iteration) => iteration.index === loopIteration?.iteration,
   )?.decision_reason;
 
+  // On the header, not the details tab, so it shows on every tab without 
duplicating the row.
+  const stateReasonDisplayed = stateReasonDisplay(taskInstance.state);
+  const stateReason = taskInstance.state_reason;
+
   const stats = [
+    // First, and a stat rather than a banner: it reports why this instance is 
in the state the
+    // header already shows, so it belongs with the rest of that instance's 
facts.
+    ...(stateReasonDisplayed === undefined || stateReason === null || 
stateReason === undefined
+      ? []
+      : [
+          {
+            label: 
translate(`taskInstance.stateReasonSummary.${stateReasonDisplayed.titleKey}`, {
+              totalTries: taskInstance.max_tries + 1,
+              tryNumber: taskInstance.try_number,
+            }),
+            value: (
+              // A reason can run to a whole traceback, so the stat shows its 
first line and the
+              // tooltip carries the rest.
+              <Tooltip content={stateReason}>
+                <Text data-testid="state-reason-stat" maxW="320px" truncate>
+                  {stateReason}
+                </Text>
+              </Tooltip>
+            ),
+          },
+        ]),
     { label: translate("task.operator"), value: taskInstance.operator_name },
     ...(taskInstance.map_index > -1
       ? [{ label: translate("mapIndex"), value: 
taskInstance.rendered_map_index }]
@@ -133,24 +158,8 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
   // Stable dialog state at header/page level
   const [clearOpen, setClearOpen] = useState(false);
 
-  // On the header, not the details tab, so it shows on every tab without 
duplicating the row.
-  const stateReasonDisplayed = stateReasonDisplay(taskInstance.state);
-  const stateReason = taskInstance.state_reason;
-
   return (
     <Box display="flex" flexDirection="column" gap={3}>
-      {stateReasonDisplayed === undefined || stateReason === null || 
stateReason === undefined ? undefined : (
-        <Alert
-          data-testid="state-reason-alert"
-          status={stateReasonDisplayed.status}
-          
title={translate(`taskInstance.stateReasonSummary.${stateReasonDisplayed.titleKey}`,
 {
-            totalTries: taskInstance.max_tries + 1,
-            tryNumber: taskInstance.try_number,
-          })}
-        >
-          {stateReason}
-        </Alert>
-      )}
       <HeaderCard
         actions={
           <>
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/stateReason.ts 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/stateReason.ts
index 60f7051fab5..1a5f89f88fe 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/stateReason.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/stateReason.ts
@@ -18,14 +18,14 @@
  */
 import type { TaskInstanceState } from "openapi/requests/types.gen";
 
-type StateReasonDisplay = { status: "error" | "warning"; titleKey: string };
+type StateReasonDisplay = { titleKey: string };
 
-// The header banner and the per-try row both look the state up here, so a 
state added to one
+// The header stat and the per-try row both look the state up here, so a state 
added to one
 // surface cannot be forgotten on the other: it has to bring a title with it. 
Keyed by
 // TaskInstanceState so a misspelt state is a compile error rather than a row 
that never renders.
 const STATE_REASON_DISPLAY: Partial<Record<TaskInstanceState, 
StateReasonDisplay>> = {
-  failed: { status: "error", titleKey: "failed" },
-  up_for_retry: { status: "warning", titleKey: "upForRetry" },
+  failed: { titleKey: "failed" },
+  up_for_retry: { titleKey: "upForRetry" },
 };
 
 export const stateReasonDisplay = (state: TaskInstanceState | null | 
undefined) =>
diff --git a/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts 
b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
index c42e0c47995..89e7a25b093 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/backfill.spec.ts
@@ -129,8 +129,8 @@ test.describe("Backfill", () => {
         toDate: dates.to,
       });
 
-      // Navigate to verify UI reflects the paused state, then test toggle 
cycle.
-      await backfillPage.navigateToDagDetail(testDagId);
+      // The controls live with the backfill row, so assert them on the 
Backfills tab.
+      await backfillPage.navigateToBackfillsTab(testDagId);
       await expect(backfillPage.unpauseButton).toBeVisible({ timeout: 15_000 
});
 
       await backfillPage.togglePauseState();
@@ -150,7 +150,7 @@ test.describe("Backfill", () => {
         toDate: dates.to,
       });
 
-      await backfillPage.navigateToDagDetail(testDagId);
+      await backfillPage.navigateToBackfillsTab(testDagId);
       await expect(backfillPage.unpauseButton).toBeVisible({ timeout: 15_000 
});
 
       await backfillPage.clickCancelButton();
@@ -171,13 +171,11 @@ test.describe("Backfill", () => {
 
       await backfillPage.cancelBackfillViaApi(backfillId);
 
-      // Verify UI: no pause/resume controls visible after cancel.
-      await backfillPage.navigateToDagDetail(testDagId);
+      // Assert absence where the controls would render, so the check can fail.
+      await backfillPage.navigateToBackfillsTab(testDagId);
       await expect(backfillPage.pauseOrUnpauseButton).not.toBeVisible();
 
       // Verify: completedAt is set in backfills table.
-      await backfillPage.navigateToBackfillsTab(testDagId);
-
       const details = await backfillPage.getBackfillDetailsByDateRange({
         fromDate: dates.from,
         toDate: dates.to,

Reply via email to