This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun 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 2d426418c4f UI: Show deactivated state for stale DAGs (#65214)
2d426418c4f is described below

commit 2d426418c4f0b98d706fbb0e29af8ecb1e5bc8e6
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Apr 14 16:20:15 2026 +0200

    UI: Show deactivated state for stale DAGs (#65214)
    
    When a DAG becomes stale or deactivated, the UI still shows
    active-oriented controls like the pause toggle, parse action, and
    next-run information. This change makes the deactivated state explicit
    by showing a badge and hiding controls that imply the DAG is still
    schedulable.
    
    - Add reusable DagDeactivatedBadge component
    - Show badge instead of pause toggle for stale DAGs in header and
      breadcrumb
    - Hide next-run stat and parse action for stale DAGs
    - Add regression test for stale DAG header behavior
    
    Fixes #63800
---
 .../src/airflow/ui/public/i18n/locales/en/dag.json |  3 +
 .../ui/src/components/DagDeactivatedBadge.tsx      | 26 +++++++++
 .../ui/src/layouts/Details/DagBreadcrumb.tsx       |  5 +-
 .../ui/src/layouts/Details/DetailsLayout.tsx       |  6 +-
 .../src/airflow/ui/src/pages/Dag/Header.test.tsx   | 65 ++++++++++++++++++++++
 .../src/airflow/ui/src/pages/Dag/Header.tsx        | 36 ++++++++----
 6 files changed, 125 insertions(+), 16 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 563b53eefe1..cd0dd22b087 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -52,6 +52,9 @@
     "buttons": {
       "advanced": "Advanced",
       "dagDocs": "Dag Docs"
+    },
+    "status": {
+      "deactivated": "Deactivated"
     }
   },
   "logs": {
diff --git a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx 
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx
new file mode 100644
index 00000000000..ad915b256be
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx
@@ -0,0 +1,26 @@
+/*!
+ * 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";
+
+export const DagDeactivatedBadge = () => {
+  const { t: translate } = useTranslation("dag");
+
+  return <Badge 
colorPalette="orange">{translate("header.status.deactivated")}</Badge>;
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
index d877b4daa1b..822e8d7e663 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -27,6 +27,7 @@ import {
   useTaskServiceGetTask,
 } from "openapi/queries";
 import { BreadcrumbStats } from "src/components/BreadcrumbStats";
+import { DagDeactivatedBadge } from "src/components/DagDeactivatedBadge";
 import { StateBadge } from "src/components/StateBadge";
 import { TogglePause } from "src/components/TogglePause";
 import { isStatePending, useAutoRefresh } from "src/utils";
@@ -65,7 +66,9 @@ export const DagBreadcrumb = () => {
     [
       {
         label: dag?.dag_display_name ?? dagId,
-        labelExtra: (
+        labelExtra: dag?.is_stale ? (
+          <DagDeactivatedBadge />
+        ) : (
           <TogglePause
             dagDisplayName={dag?.dag_display_name}
             dagId={dagId}
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 dd559e0ac5e..dacd6171f5d 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -78,7 +78,7 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
   const [defaultDagView] = useLocalStorage<"graph" | 
"grid">(DEFAULT_DAG_VIEW_KEY, "grid");
   const panelGroupRef = useRef<ImperativePanelGroupHandle | null>(null);
   const [dagView, setDagView] = useLocalStorage<"graph" | 
"grid">(dagViewKey(dagId), defaultDagView);
-  const [limit, setLimit] = useLocalStorage<number>(dagRunsLimitKey(dagId), 
10);
+  const [limit, setLimit] = useLocalStorage(dagRunsLimitKey(dagId), 10);
   const [runAfterGte, setRunAfterGte] = useLocalStorage<string | 
undefined>(runAfterGteKey(dagId), undefined);
   const [runAfterLte, setRunAfterLte] = useLocalStorage<string | 
undefined>(runAfterLteKey(dagId), undefined);
   const [runTypeFilter, setRunTypeFilter] = useLocalStorage<DagRunType | 
undefined>(
@@ -94,9 +94,9 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
     undefined,
   );
 
-  const [showGantt, setShowGantt] = 
useLocalStorage<boolean>(showGanttKey(dagId), false);
+  const [showGantt, setShowGantt] = useLocalStorage(showGanttKey(dagId), 
false);
   // Global setting: applies to all Dags (intentionally not scoped to dagId)
-  const [showVersionIndicatorMode, setShowVersionIndicatorMode] = 
useLocalStorage<VersionIndicatorOptions>(
+  const [showVersionIndicatorMode, setShowVersionIndicatorMode] = 
useLocalStorage(
     `version_indicator_display_mode`,
     VersionIndicatorOptions.ALL,
   );
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
new file mode 100644
index 00000000000..9d68c7b8e85
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
@@ -0,0 +1,65 @@
+/*!
+ * 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 type { DAGDetailsResponse } from "openapi-gen/requests/types.gen";
+import { describe, expect, it } from "vitest";
+
+import i18n from "src/i18n/config";
+import { MOCK_DAG } from "src/mocks/handlers/dag";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { Header } from "./Header";
+
+const mockDag = {
+  ...MOCK_DAG,
+  active_runs_count: 0,
+  allowed_run_types: [],
+  bundle_name: "dags-folder",
+  bundle_version: "1",
+  default_args: {},
+  fileloc: "/files/dags/stale_dag.py",
+  is_favorite: false,
+  is_stale: true,
+  last_parse_duration: 0.23,
+  // `null` matches the API response shape for DAGs without version metadata.
+  // eslint-disable-next-line unicorn/no-null
+  latest_dag_version: null,
+  next_dagrun_logical_date: "2024-08-22T00:00:00+00:00",
+  next_dagrun_run_after: "2024-08-22T19:00:00+00:00",
+  owner_links: {},
+  relative_fileloc: "stale_dag.py",
+  tags: [],
+  timetable_partitioned: false,
+  timetable_summary: "* * * * *",
+} as unknown as DAGDetailsResponse;
+
+describe("Header", () => {
+  it("shows a deactivated badge and hides stale-only next actions for stale 
dags", () => {
+    render(
+      <Wrapper>
+        <Header dag={mockDag} />
+      </Wrapper>,
+    );
+
+    
expect(screen.getByText(i18n.t("dag:header.status.deactivated"))).toBeInTheDocument();
+    
expect(screen.queryByText(i18n.t("dag:dagDetails.nextRun"))).not.toBeInTheDocument();
+    expect(screen.queryByRole("button", { name: "Reparse Dag" 
})).not.toBeInTheDocument();
+  });
+});
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 6af9129a316..99acda13f05 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -26,6 +26,7 @@ import { DagIcon } from "src/assets/DagIcon";
 import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton";
 import { FavoriteDagButton } from 
"src/components/DagActions/FavoriteDagButton";
 import { ParseDagButton } from "src/components/DagActions/ParseDagButton";
+import { DagDeactivatedBadge } from "src/components/DagDeactivatedBadge";
 import DagRunInfo from "src/components/DagRunInfo";
 import { DagVersion } from "src/components/DagVersion";
 import DisplayMarkdownButton from "src/components/DisplayMarkdownButton";
@@ -56,6 +57,21 @@ export const Header = ({
   const { t: translate } = useTranslation(["common", "dag"]);
   // We would still like to show the dagId even if the dag object hasn't 
loaded yet
   const { dagId } = useParams();
+  const isStale = dag?.is_stale;
+
+  const nextRunStat = isStale
+    ? []
+    : [
+        {
+          label: translate("dagDetails.nextRun"),
+          value: Boolean(dag?.next_dagrun_run_after) ? (
+            <DagRunInfo
+              logicalDate={dag?.next_dagrun_logical_date}
+              runAfter={dag?.next_dagrun_run_after as string}
+            />
+          ) : undefined,
+        },
+      ];
 
   const stats = [
     {
@@ -88,15 +104,7 @@ export const Header = ({
           </Link>
         ) : undefined,
     },
-    {
-      label: translate("dagDetails.nextRun"),
-      value: Boolean(dag?.next_dagrun_run_after) ? (
-        <DagRunInfo
-          logicalDate={dag?.next_dagrun_logical_date}
-          runAfter={dag?.next_dagrun_run_after as string}
-        />
-      ) : undefined,
-    },
+    ...nextRunStat,
     {
       label: translate("dagDetails.maxActiveRuns"),
       value:
@@ -132,7 +140,7 @@ export const Header = ({
               />
             )}
             <FavoriteDagButton dagId={dag.dag_id} isFavorite={dag.is_favorite} 
/>
-            <ParseDagButton dagId={dag.dag_id} fileToken={dag.file_token} />
+            {isStale ? undefined : <ParseDagButton dagId={dag.dag_id} 
fileToken={dag.file_token} />}
             <DeleteDagButton dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} />
           </>
         )
@@ -140,8 +148,12 @@ export const Header = ({
       icon={<DagIcon />}
       stats={stats}
       subTitle={
-        dag !== undefined && (
-          <TogglePause dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} isPaused={dag.is_paused} />
+        isStale ? (
+          <DagDeactivatedBadge />
+        ) : (
+          dag !== undefined && (
+            <TogglePause dagDisplayName={dag.dag_display_name} 
dagId={dag.dag_id} isPaused={dag.is_paused} />
+          )
         )
       }
       title={dag?.dag_display_name ?? dagId}

Reply via email to