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}