This is an automated email from the ASF dual-hosted git repository.
dheerajturaga 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 417a33b5432 UI: Fold draining into the Dags Paused state filter
(#74668)
417a33b5432 is described below
commit 417a33b5432ae61ba1407ecb55600095fb1f18dc
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Oct 13 17:32:42 2026 -0400
UI: Fold draining into the Dags Paused state filter (#74668)
The Dags list carried two state filters, Paused state and Scheduling state,
and ANDed them. Because a Dag is in exactly one scheduling state, three of
the six combinations they allowed could never match a row, and the other
three only restated a single pill. The pair had no useful combination at
all, and nothing on screen explained an empty list.
Draining is now a third choice inside Paused state rather than a filter of
its own. It belongs there: a draining Dag has not been paused yet, it has
only stopped taking new runs, so it sits inside "Not paused" rather than
beside it.
Each choice keeps writing the narrowest param that already existed, so
existing links and the hide_paused_dags_by_default seed are untouched and
the API is unchanged. Links carrying the short-lived scheduling_state param
still resolve where a choice can represent them; scheduling_state=active
cannot be, since no single choice excludes draining, so it is ignored rather
than narrowing the list with no pill to explain it.
Co-authored-by: Claude <[email protected]>
---
.../airflow/ui/public/i18n/locales/ca/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/de/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/en/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/fr/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/ko/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/pl/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/zh-CN/dags.json | 14 ++--
.../airflow/ui/public/i18n/locales/zh-TW/dags.json | 14 ++--
.../airflow/ui/src/components/DrainingBadge.tsx | 4 +-
.../FilterBar/filters/pausedStateParams.ts | 82 ++++++++++++++++++++
.../src/airflow/ui/src/components/TogglePause.tsx | 2 +-
.../src/airflow/ui/src/constants/filterConfigs.tsx | 22 +++---
.../src/airflow/ui/src/mocks/handlers/dags.ts | 90 +++++++++++++++++-----
.../DagsList/DagsFilters/DagsFilters.test.tsx | 80 +++++++++++++++++--
.../src/pages/DagsList/DagsFilters/DagsFilters.tsx | 1 -
.../ui/src/pages/DagsList/DagsList.test.tsx | 9 ++-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 29 +++----
.../src/airflow/ui/src/utils/useFiltersHandler.ts | 1 -
18 files changed, 294 insertions(+), 138 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ca/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ca/dags.json
index 2dbb0df5ef3..3ff88bb118e 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ca/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ca/dags.json
@@ -6,6 +6,11 @@
"warning": "Això eliminarà tota la metadada relacionada amb el Dag,
incloent-hi Execucions i Tasques."
}
},
+ "draining": {
+ "badgeTooltip": "Aquest Dag es pausarà quan les execucions en curs hagin
finalitzat.",
+ "label": "Drenant",
+ "tooltip": "Drenatge de {{dagDisplayName}} — es posarà en pausa quan
acabin les execucions del DAG que estan en curs. Feu clic per reprendre'l."
+ },
"favoriteDag": "Dag favorit",
"filters": {
"allRunTypes": "Tots els tipus d'execució",
@@ -109,15 +114,6 @@
"schedulingBanner": {
"message": "Aquest DAG s'està drenant. Es posarà en pausa quan acabin les
execucions del DAG que estan en curs."
},
- "schedulingState": {
- "active": "Actiu",
- "draining": "Drenant",
- "drainingBadgeTooltip": "Aquest Dag es pausarà quan les execucions en curs
hagin finalitzat.",
- "drainingTooltip": "Drenatge de {{dagDisplayName}} — es posarà en pausa
quan acabin les execucions del DAG que estan en curs. Feu clic per
reprendre'l.",
- "label": "Estat de programació",
- "paused": "Pausat",
- "placeholder": "Seleccionar l'estat de programació…"
- },
"search": {
"advanced": "Cerca avançada",
"clear": "Netejar cerca",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/de/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/de/dags.json
index c33602b393c..d7a41754ae5 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/de/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/de/dags.json
@@ -6,6 +6,11 @@
"warning": "Diese Aktion löscht alle Metadaten zu diesem Dag mit allen
Läufen und Task Instanzen."
}
},
+ "draining": {
+ "badgeTooltip": "Dieses Dag pausiert sobald alle Läufe komplett sind.",
+ "label": "Auslaufend",
+ "tooltip": "Auslaufen {{dagDisplayName}} — pausiert wenn Dag Läufe
komplett sind. Klicken zum Forstsetzen."
+ },
"favoriteDag": "Dag als Favorit hinzufügen",
"filters": {
"allRunTypes": "Alle Arten von Läufen",
@@ -109,15 +114,6 @@
"schedulingBanner": {
"message": "Dieses Dag läuft aus. Es wird pausieren sobald aktive Läufe
komplett sind."
},
- "schedulingState": {
- "active": "Aktiv",
- "draining": "Auslaufend",
- "drainingBadgeTooltip": "Dieses Dag pausiert sobald alle Läufe komplett
sind.",
- "drainingTooltip": "Auslaufen {{dagDisplayName}} — pausiert wenn Dag Läufe
komplett sind. Klicken zum Forstsetzen.",
- "label": "Planungsstatus",
- "paused": "Pausiert",
- "placeholder": "Auswahl des Planungszustands…"
- },
"search": {
"advanced": "Erweiterte Suche",
"clear": "Suche zurücksetzen",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 6771603141f..e6fd7954a83 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -6,6 +6,11 @@
"warning": "This will remove all metadata related to the
$t(common:dag_one), including Runs and $t(common:task_other)."
}
},
+ "draining": {
+ "badgeTooltip": "This Dag pauses once its running Dag runs finish.",
+ "label": "Draining",
+ "tooltip": "Draining {{dagDisplayName}} — pauses once running Dag runs
finish. Click to resume."
+ },
"favoriteDag": "Favorite $t(common:dag_one)",
"filters": {
"allRunTypes": "All Run Types",
@@ -118,15 +123,6 @@
"schedulingBanner": {
"message": "This Dag is draining. It will pause once its running Dag runs
finish."
},
- "schedulingState": {
- "active": "Active",
- "draining": "Draining",
- "drainingBadgeTooltip": "This Dag pauses once its running Dag runs
finish.",
- "drainingTooltip": "Draining {{dagDisplayName}} — pauses once running Dag
runs finish. Click to resume.",
- "label": "Scheduling state",
- "paused": "Paused",
- "placeholder": "Select scheduling state…"
- },
"search": {
"advanced": "Advanced Search",
"clear": "Clear search",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/fr/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/fr/dags.json
index 3c7eb54dafe..ce13f5e77e1 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/fr/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/fr/dags.json
@@ -6,6 +6,11 @@
"warning": "Cela supprimera toutes les métadonnées liées au Dag, y
compris les exécutions et les tâches."
}
},
+ "draining": {
+ "badgeTooltip": "Ce Dag se met en pause une fois ses exécutions de Dag en
cours terminées.",
+ "label": "Drainage en cours",
+ "tooltip": "Drainage de {{dagDisplayName}} — se met en pause une fois les
exécutions de Dag en cours terminées. Cliquez pour reprendre."
+ },
"favoriteDag": "Ajouter le Dag aux favoris",
"filters": {
"allRunTypes": "Tous les types de Run",
@@ -109,15 +114,6 @@
"schedulingBanner": {
"message": "Ce Dag est en cours de drainage. Il se mettra en pause une
fois ses exécutions de Dag en cours terminées."
},
- "schedulingState": {
- "active": "Actif",
- "draining": "Drainage en cours",
- "drainingBadgeTooltip": "Ce Dag se met en pause une fois ses exécutions de
Dag en cours terminées.",
- "drainingTooltip": "Drainage de {{dagDisplayName}} — se met en pause une
fois les exécutions de Dag en cours terminées. Cliquez pour reprendre.",
- "label": "État de planification",
- "paused": "En pause",
- "placeholder": "Sélectionner l'état de planification…"
- },
"search": {
"advanced": "Recherche avancée",
"clear": "Effacer la recherche",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json
index 05c4d4182b8..782e60da423 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dags.json
@@ -6,6 +6,11 @@
"warning": "실행 및 태스크를 포함하여 Dag와 관련된 모든 메타데이터가 제거됩니다."
}
},
+ "draining": {
+ "badgeTooltip": "진행 중인 Dag 실행이 모두 끝나면 이 Dag가 일시 중지됩니다.",
+ "label": "실행 완료 대기",
+ "tooltip": "{{dagDisplayName}}: 실행 완료 대기 중입니다. 진행 중인 Dag 실행이 모두 끝나면 일시
중지됩니다. 클릭하여 다시 활성화하세요."
+ },
"favoriteDag": "Dag 즐겨찾기",
"filters": {
"allRunTypes": "모든 실행 유형",
@@ -118,15 +123,6 @@
"schedulingBanner": {
"message": "이 Dag는 실행 완료 대기 중입니다. 진행 중인 Dag 실행이 모두 끝나면 일시 중지됩니다."
},
- "schedulingState": {
- "active": "활성",
- "draining": "실행 완료 대기",
- "drainingBadgeTooltip": "진행 중인 Dag 실행이 모두 끝나면 이 Dag가 일시 중지됩니다.",
- "drainingTooltip": "{{dagDisplayName}}: 실행 완료 대기 중입니다. 진행 중인 Dag 실행이 모두
끝나면 일시 중지됩니다. 클릭하여 다시 활성화하세요.",
- "label": "스케줄링 상태",
- "paused": "일시 중지됨",
- "placeholder": "스케줄링 상태 선택..."
- },
"search": {
"advanced": "고급 검색",
"clear": "검색 지우기",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pl/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/pl/dags.json
index 8cf3a513f0f..d28bce86a07 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pl/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pl/dags.json
@@ -6,6 +6,11 @@
"warning": "Wszystkie metadane, włączając metadane Dagów, wykonań Dagów
i zadań zostaną usunięte."
}
},
+ "draining": {
+ "badgeTooltip": "Ten Dag zostanie wstrzymany, gdy zakończą się jego
trwające wykonania.",
+ "label": "Wygaszane",
+ "tooltip": "Wygaszanie {{dagDisplayName}} — wstrzymanie nastąpi po
zakończeniu trwających wykonań. Kliknij, aby wznowić."
+ },
"favoriteDag": "Ulubiony Dag",
"filters": {
"allRunTypes": "Wszystkie Typy Wykonań Dagów",
@@ -116,15 +121,6 @@
"schedulingBanner": {
"message": "Ten Dag jest wygaszany. Zostanie wstrzymany, gdy zakończą się
jego trwające wykonania."
},
- "schedulingState": {
- "active": "Aktywne",
- "draining": "Wygaszane",
- "drainingBadgeTooltip": "Ten Dag zostanie wstrzymany, gdy zakończą się
jego trwające wykonania.",
- "drainingTooltip": "Wygaszanie {{dagDisplayName}} — wstrzymanie nastąpi po
zakończeniu trwających wykonań. Kliknij, aby wznowić.",
- "label": "Stan harmonogramu",
- "paused": "Wstrzymane",
- "placeholder": "Wybierz stan harmonogramu…"
- },
"search": {
"advanced": "Wyszukiwanie zaawansowane",
"clear": "Wyczyść wyszukiwanie",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dags.json
index 083982f6303..82b9ee7c046 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dags.json
@@ -6,6 +6,11 @@
"warning": "这将会删除所有与此 Dag 相关的系统数据,包括 Dag 执行与任务。"
}
},
+ "draining": {
+ "badgeTooltip": "此 Dag 将在当前的 Dag 执行全部完成后自动暂停。",
+ "label": "完成后将暂停",
+ "tooltip": "{{dagDisplayName}} 将在当前的 Dag 执行全部完成后自动暂停。点击可恢复调度。"
+ },
"favoriteDag": "将 Dag 加入收藏",
"filters": {
"allRunTypes": "全部执行类型",
@@ -98,15 +103,6 @@
"schedulingBanner": {
"message": "此 Dag 将在当前的 Dag 执行全部完成后自动暂停。"
},
- "schedulingState": {
- "active": "已启用",
- "draining": "完成后将暂停",
- "drainingBadgeTooltip": "此 Dag 将在当前的 Dag 执行全部完成后自动暂停。",
- "drainingTooltip": "{{dagDisplayName}} 将在当前的 Dag 执行全部完成后自动暂停。点击可恢复调度。",
- "label": "调度状态",
- "paused": "暂停",
- "placeholder": "选择调度状态..."
- },
"search": {
"advanced": "高级搜索",
"clear": "清除搜索",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dags.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dags.json
index c138e795bc9..db9c542cad7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dags.json
@@ -6,6 +6,11 @@
"warning": "這將會刪除所有與此 Dag 相關的系統資料,包括 Dag 執行與任務。"
}
},
+ "draining": {
+ "badgeTooltip": "待目前的執行全部結束後,此 Dag 將自動暫停。",
+ "label": "完成後將暫停",
+ "tooltip": "{{dagDisplayName}} 會在目前的執行全部結束後自動暫停。點選以恢復排程。"
+ },
"favoriteDag": "將 Dag 加入最愛",
"filters": {
"allRunTypes": "全部執行類型",
@@ -118,15 +123,6 @@
"schedulingBanner": {
"message": "待目前的執行全部結束後,此 Dag 將自動暫停。"
},
- "schedulingState": {
- "active": "啟用中",
- "draining": "完成後將暫停",
- "drainingBadgeTooltip": "待目前的執行全部結束後,此 Dag 將自動暫停。",
- "drainingTooltip": "{{dagDisplayName}} 會在目前的執行全部結束後自動暫停。點選以恢復排程。",
- "label": "排程狀態",
- "paused": "暫停",
- "placeholder": "選擇排程狀態…"
- },
"search": {
"advanced": "進階搜尋",
"clear": "清除搜尋",
diff --git a/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
b/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
index b7c93b7990e..cafbc9c3347 100644
--- a/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DrainingBadge.tsx
@@ -28,11 +28,11 @@ export const DrainingBadge = (props: BadgeProps) => {
const { t: translate } = useTranslation("dags");
return (
- <Tooltip content={translate("schedulingState.drainingBadgeTooltip")}>
+ <Tooltip content={translate("draining.badgeTooltip")}>
<span>
<StateBadge colorPalette="warning" data-testid="draining-badge"
variant="subtle" {...props}>
<MdHourglassTop />
- {translate("schedulingState.draining")}
+ {translate("draining.label")}
</StateBadge>
</span>
</Tooltip>
diff --git
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/pausedStateParams.ts
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/pausedStateParams.ts
new file mode 100644
index 00000000000..f7543ffe6f7
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/pausedStateParams.ts
@@ -0,0 +1,82 @@
+/*!
+ * 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 { SearchParamsKeys } from "src/constants/searchParams";
+
+import type { FilterValue } from "../types";
+
+/**
+ * The Paused state pill covers three choices backed by the two URL params
that already exist:
+ *
+ * "not_paused" → ``paused=false`` unpaused, which includes
draining Dags
+ * "paused" → ``paused=true``
+ * "draining" → ``scheduling_state=draining``
+ *
+ * Draining is a subset of not-paused rather than a sibling of it — a draining
Dag has not been
+ * paused yet, it just stopped taking new runs — so it needs the narrower
param. Keeping
+ * ``paused`` for the other two leaves existing links and the
``hide_paused_dags_by_default``
+ * seed working untouched.
+ */
+export type PausedStateValue = "draining" | "not_paused" | "paused";
+
+export const pausedStateToSearchParams = (value: FilterValue): Record<string,
string | undefined> => {
+ const paused = value === "not_paused" ? "false" : undefined;
+
+ return {
+ [SearchParamsKeys.PAUSED]: value === "paused" ? "true" : paused,
+ [SearchParamsKeys.SCHEDULING_STATE]: value === "draining" ? "draining" :
undefined,
+ };
+};
+
+export const pausedStateFromSearchParams = (params: URLSearchParams):
PausedStateValue | undefined => {
+ const schedulingState = params.get(SearchParamsKeys.SCHEDULING_STATE);
+
+ // Checked first: a draining Dag is also unpaused, so ``paused=false``
alongside it would
+ // otherwise win and widen the pill back to every unpaused Dag.
+ if (schedulingState === "draining") {
+ return "draining";
+ }
+
+ // ``scheduling_state=paused`` is the same set as ``paused=true``, so links
written against the
+ // short-lived Scheduling state filter keep working. ``=active`` has no
equivalent here — it
+ // excludes draining Dags, which no single choice does — so it falls through
and is ignored.
+ if (schedulingState === "paused") {
+ return "paused";
+ }
+
+ const paused = params.get(SearchParamsKeys.PAUSED);
+
+ if (paused === "true") {
+ return "paused";
+ }
+
+ return paused === "false" ? "not_paused" : undefined;
+};
+
+/** The query params to send for a pill value, so the list can never filter on
more than the pill shows. */
+export const pausedStateToQuery = (value: PausedStateValue | undefined) => {
+ let paused;
+
+ if (value === "paused") {
+ paused = true;
+ } else if (value === "not_paused") {
+ paused = false;
+ }
+
+ return { paused, schedulingState: value === "draining" ? ("draining" as
const) : undefined };
+};
diff --git a/airflow-core/src/airflow/ui/src/components/TogglePause.tsx
b/airflow-core/src/airflow/ui/src/components/TogglePause.tsx
index 892048ecdb5..33b1874b858 100644
--- a/airflow-core/src/airflow/ui/src/components/TogglePause.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TogglePause.tsx
@@ -95,7 +95,7 @@ export const TogglePause = ({
const label =
state === "draining"
- ? translate("dags:schedulingState.drainingTooltip", { dagDisplayName:
displayName })
+ ? translate("dags:draining.tooltip", { dagDisplayName: displayName })
: `${translate(state === "paused" ? "common:unpause" : "common:pause")}
${displayName}`;
const actionLabel = pendingState === "paused" ? translate("common:pause") :
translate("common:unpause");
diff --git a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
index 6eb493ded3b..041af1d640c 100644
--- a/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
+++ b/airflow-core/src/airflow/ui/src/constants/filterConfigs.tsx
@@ -46,6 +46,10 @@ import type { FilterConfig } from "src/components/FilterBar";
import { RunStateFilter } from
"src/components/FilterBar/filters/RunStateFilter";
import { TagsFilter } from "src/components/FilterBar/filters/TagsFilter";
import { TimetableTypeFilter } from
"src/components/FilterBar/filters/TimetableTypeFilter";
+import {
+ pausedStateFromSearchParams,
+ pausedStateToSearchParams,
+} from "src/components/FilterBar/filters/pausedStateParams";
import {
runStateFromSearchParams,
runStateToSearchParams,
@@ -364,13 +368,16 @@ export const useFilterConfigs = () => {
type: FilterTypes.TEXT,
},
[SearchParamsKeys.PAUSED]: {
+ fromSearchParams: pausedStateFromSearchParams,
icon: <MdPause />,
label: translate("dags:filters.pausedState"),
options: [
- { label: translate("dags:filters.paused.active"), value: "false" },
- { label: translate("dags:filters.paused.paused"), value: "true" },
+ { label: translate("dags:filters.paused.active"), value: "not_paused"
},
+ { label: translate("dags:draining.label"), value: "draining" },
+ { label: translate("dags:filters.paused.paused"), value: "paused" },
],
placeholder: translate("dags:filters.pausedPlaceholder"),
+ toSearchParams: pausedStateToSearchParams,
type: FilterTypes.SELECT,
},
[SearchParamsKeys.POOL_NAME_PATTERN]: {
@@ -469,17 +476,6 @@ export const useFilterConfigs = () => {
})),
type: FilterTypes.SELECT,
},
- [SearchParamsKeys.SCHEDULING_STATE]: {
- icon: <MdSchedule />,
- label: translate("dags:schedulingState.label"),
- options: [
- { label: translate("dags:schedulingState.active"), value: "active" },
- { label: translate("dags:schedulingState.draining"), value: "draining"
},
- { label: translate("dags:schedulingState.paused"), value: "paused" },
- ],
- placeholder: translate("dags:schedulingState.placeholder"),
- type: FilterTypes.SELECT,
- },
[SearchParamsKeys.START_DATE_RANGE]: {
endKey: SearchParamsKeys.START_DATE_LTE,
icon: <MdDateRange />,
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
index e7564dec209..64b78391d8f 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/dags.ts
@@ -112,13 +112,55 @@ export const pausedDag = {
timetable_type: "NullTimetable",
};
-const filterDags = ({ paused, timetableTypes }: { paused: string | null;
timetableTypes: Array<string> }) => {
- let allDags = [successDag, failedDag, pausedDag];
+export const drainingDag = {
+ dag_display_name: "draining_dag",
+ dag_id: "draining_dag",
+ file_token:
+
".eJw9yUsOgCAMBcC7cAB7JPISizR82kCJcnvjxtUsJlDWxlQwPEvhjU7TV0pk27N2goxU9f7lB80qxxPXJF-uQ1CjY5avI0wO2-EFouohiw.fhdU5u0Pb7lElEd-AUUXqjHSsdo",
+ fileloc: "/airflow/dags/draining_dag.py",
+ has_import_errors: false,
+ has_task_concurrency_limits: false,
+ has_unfinished_runs: true,
+ is_favorite: false,
+ is_paused: false,
+ is_stale: false,
+ last_parsed_time: "2025-01-13T07:34:01.593459Z",
+ latest_dag_runs: [],
+ max_active_runs: 16,
+ max_active_tasks: 16,
+ max_consecutive_failed_dag_runs: 0,
+ owners: ["airflow"],
+ pending_actions: [],
+ scheduling_state: "draining",
+ tags: [{ dag_id: "draining_dag", name: "example" }],
+ timetable_description: "Never, external triggers only",
+ timetable_type: "NullTimetable",
+};
+
+export const defaultDags = [successDag, failedDag, pausedDag];
+
+const filterDags = ({
+ dags,
+ paused,
+ schedulingState,
+ timetableTypes,
+}: {
+ dags: Array<(typeof defaultDags)[number] | typeof drainingDag>;
+ paused: string | null;
+ schedulingState: string | null;
+ timetableTypes: Array<string>;
+}) => {
+ let allDags = dags;
if (timetableTypes.length > 0) {
allDags = allDags.filter((dag) =>
timetableTypes.includes(dag.timetable_type));
}
+ // Narrower than ``paused=false``: a draining Dag is unpaused, so the two
are not alternatives.
+ if (schedulingState === "draining") {
+ allDags = allDags.filter((dag) => "scheduling_state" in dag &&
dag.scheduling_state === "draining");
+ }
+
if (paused === "true") {
return allDags.filter((dag) => dag.is_paused);
}
@@ -126,25 +168,20 @@ const filterDags = ({ paused, timetableTypes }: { paused:
string | null; timetab
return paused === "false" ? allDags.filter((dag) => !dag.is_paused) :
allDags;
};
-export const handlers: Array<HttpHandler> = [
- http.get("/ui/dags/timetable_types", ({ request }) => {
- const url = new URL(request.url);
- const prefix = url.searchParams.get("timetable_type_prefix_pattern") ?? "";
- const timetableTypes = ["CronTriggerTimetable",
"NullTimetable"].filter((timetableType) =>
- timetableType.startsWith(prefix),
- );
-
- return HttpResponse.json({
- timetable_types: timetableTypes,
- total_entries: timetableTypes.length,
- });
- }),
+/**
+ * ``/ui/dags`` over a caller-chosen fixture, so a suite can add the Dags it
needs without
+ * enlarging the set every other suite renders.
+ */
+export const dagsListHandler = (
+ dags: Array<(typeof defaultDags)[number] | typeof drainingDag> = defaultDags,
+) =>
http.get("/ui/dags", ({ request }) => {
const url = new URL(request.url);
const lastDagRunState = url.searchParams.get("last_dag_run_state");
const anyDagRunState = url.searchParams.get("dag_run_state");
const orderBy = url.searchParams.get("order_by");
const paused = url.searchParams.get("paused");
+ const schedulingState = url.searchParams.get("scheduling_state");
const timetableTypes = url.searchParams.getAll("timetable_type");
if (lastDagRunState === "success") {
@@ -168,17 +205,32 @@ export const handlers: Array<HttpHandler> = [
});
}
- let dags = filterDags({ paused, timetableTypes });
+ let filtered = filterDags({ dags, paused, schedulingState, timetableTypes
});
if (orderBy === "last_run_run_after" || orderBy === "-last_run_run_after")
{
- dags = [failedDag, successDag, pausedDag].filter((dag) =>
dags.includes(dag));
+ filtered = [failedDag, successDag, pausedDag, drainingDag].filter((dag)
=> filtered.includes(dag));
}
return HttpResponse.json({
- dags,
- total_entries: dags.length,
+ dags: filtered,
+ total_entries: filtered.length,
+ });
+ });
+
+export const handlers: Array<HttpHandler> = [
+ http.get("/ui/dags/timetable_types", ({ request }) => {
+ const url = new URL(request.url);
+ const prefix = url.searchParams.get("timetable_type_prefix_pattern") ?? "";
+ const timetableTypes = ["CronTriggerTimetable",
"NullTimetable"].filter((timetableType) =>
+ timetableType.startsWith(prefix),
+ );
+
+ return HttpResponse.json({
+ timetable_types: timetableTypes,
+ total_entries: timetableTypes.length,
});
}),
+ dagsListHandler(),
http.get("/api/v2/dags", ({ request }) => {
const url = new URL(request.url);
const lastDagRunState = url.searchParams.get("last_dag_run_state");
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
index ebbf780f373..20ee06d9fd0 100644
---
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
@@ -18,8 +18,11 @@
*/
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
-import { afterEach, describe, it, expect, vi } from "vitest";
+import { setupServer, type SetupServer } from "msw/node";
+import { afterAll, afterEach, beforeAll, describe, it, expect, vi } from
"vitest";
+import { handlers } from "src/mocks/handlers";
+import { dagsListHandler, defaultDags, drainingDag } from
"src/mocks/handlers/dags";
import { AppWrapper } from "src/utils/AppWrapper";
const mockConfig: Record<string, unknown> = {
@@ -38,6 +41,19 @@ vi.mock("src/queries/useConfig", () => ({
useConfig: (key: string) => mockConfig[key],
}));
+// Only the draining assertions need a fourth Dag, and every extra row is
re-rendered by each
+// test that leans on the default fixture, so it is served per-test rather
than globally.
+let server: SetupServer;
+
+beforeAll(() => {
+ server = setupServer(...handlers);
+ server.listen({ onUnhandledFrame: "bypass" });
+});
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+const withDrainingDag = () => server.use(dagsListHandler([...defaultDags,
drainingDag]));
+
const addFilter = async (key: string) => {
fireEvent.click(screen.getByTestId("add-filter-button"));
fireEvent.click(await screen.findByTestId(`add-filter-${key}`));
@@ -61,6 +77,14 @@ describe("Paused filter with hide_paused_dags_by_default
enabled", () => {
expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
});
+ it("keeps draining dags under the default, since they are not paused yet",
async () => {
+ withDrainingDag();
+ render(<AppWrapper initialEntries={["/dags"]} />);
+
+ await waitFor(() =>
expect(screen.getByText("draining_dag")).toBeInTheDocument());
+ expect(screen.queryByText("paused_dag")).not.toBeInTheDocument();
+ });
+
it("shows the default as a pill rather than filtering invisibly", async ()
=> {
render(<AppWrapper initialEntries={["/dags"]} />);
@@ -71,12 +95,27 @@ describe("Paused filter with hide_paused_dags_by_default
enabled", () => {
expect(pill).toHaveTextContent("filters.paused.active");
});
- it("does not add the default paused filter when a scheduling state is
already selected", async () => {
- render(<AppWrapper initialEntries={["/dags?scheduling_state=active"]} />);
+ it("restores the draining choice from a scheduling_state url", async () => {
+ withDrainingDag();
+ render(<AppWrapper initialEntries={["/dags?scheduling_state=draining"]}
/>);
- await waitFor(() =>
expect(screen.getByTestId("scheduling_state-pill")).toBeInTheDocument());
+ const pill = await screen.findByTestId("paused-pill");
- expect(screen.queryByTestId("paused-pill")).not.toBeInTheDocument();
+ // Draining is narrower than not-paused, so the shared pill has to report
the narrower one.
+ expect(pill).toHaveTextContent("draining.label");
+ await waitFor(() =>
expect(screen.getByText("draining_dag")).toBeInTheDocument());
+
expect(screen.queryByText("tutorial_taskflow_api_success")).not.toBeInTheDocument();
+ });
+
+ it("offers one state pill rather than a separate scheduling state", async ()
=> {
+ render(<AppWrapper initialEntries={["/dags"]} />);
+
+ await waitFor(() =>
expect(screen.getByTestId("paused-pill")).toBeInTheDocument());
+
expect(screen.queryByTestId("scheduling_state-pill")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+
+
expect(screen.queryByTestId("add-filter-scheduling_state")).not.toBeInTheDocument();
});
it("shows all dags after removing the paused filter", async () => {
@@ -97,7 +136,7 @@ describe("Paused filter with hide_paused_dags_by_default
enabled", () => {
await waitFor(() =>
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument());
- await selectPillOption("paused", "true");
+ await selectPillOption("paused", "paused");
await waitFor(() =>
expect(screen.getByText("paused_dag")).toBeInTheDocument());
await waitFor(() =>
expect(screen.queryByText("tutorial_taskflow_api_success")).not.toBeInTheDocument());
});
@@ -179,3 +218,32 @@ describe("Paused filter with hide_paused_dags_by_default
enabled", () => {
expect(await
screen.findByTestId("preset-filters-button")).toBeInTheDocument();
});
});
+
+describe("Links written before draining moved into the pill", () => {
+ it.each([
+ {
+ entries: "/dags?paused=false",
+ expected: "filters.paused.active",
+ shown: "tutorial_taskflow_api_success",
+ },
+ { entries: "/dags?paused=true", expected: "filters.paused.paused", shown:
"paused_dag" },
+ { entries: "/dags?scheduling_state=paused", expected:
"filters.paused.paused", shown: "paused_dag" },
+ ])("restores $entries onto the Paused state pill", async ({ entries,
expected, shown }) => {
+ render(<AppWrapper initialEntries={[entries]} />);
+
+ const pill = await screen.findByTestId("paused-pill");
+
+ expect(pill).toHaveTextContent(expected);
+ await waitFor(() => expect(screen.getByText(shown)).toBeInTheDocument());
+ });
+
+ it("ignores scheduling_state=active rather than filtering with no pill to
explain it", async () => {
+ render(<AppWrapper initialEntries={["/dags?scheduling_state=active"]} />);
+
+ // No choice means "not paused and not draining", so the param is inert
instead of narrowing
+ // the list invisibly.
+ await waitFor(() =>
expect(screen.getByText("paused_dag")).toBeInTheDocument());
+
expect(screen.getByText("tutorial_taskflow_api_success")).toBeInTheDocument();
+ expect(screen.queryByTestId("paused-pill")).not.toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
index 9081aa9e807..d84ecc14fb6 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
@@ -31,7 +31,6 @@ export const DagsFilters = () => {
const searchParamKeys: Array<FilterableSearchParamsKeys> = [
SearchParamsKeys.PAUSED,
- SearchParamsKeys.SCHEDULING_STATE,
SearchParamsKeys.RUN_STATE,
SearchParamsKeys.NEEDS_REVIEW,
SearchParamsKeys.TAGS,
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
index d41950df2d0..1c2e0c61986 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.test.tsx
@@ -43,7 +43,7 @@ afterEach(() => {
afterAll(() => server.close());
describe("Dag Filters", () => {
- it("passes an exact scheduling state from the URL to the API", async () => {
+ it("passes the draining scheduling state from the URL to the API", async ()
=> {
let requestedSchedulingState: string | null = null;
server.use(
@@ -54,10 +54,11 @@ describe("Dag Filters", () => {
}),
);
- render(<AppWrapper initialEntries={["/dags?scheduling_state=active"]} />);
+ render(<AppWrapper initialEntries={["/dags?scheduling_state=draining"]}
/>);
- await waitFor(() => expect(requestedSchedulingState).toBe("active"));
- expect(await
screen.findByTestId("scheduling_state-pill")).toHaveTextContent("schedulingState.active");
+ await waitFor(() => expect(requestedSchedulingState).toBe("draining"));
+ // Draining is a choice within the Paused state pill, not a pill of its
own.
+ expect(await
screen.findByTestId("paused-pill")).toHaveTextContent("draining.label");
});
it("Filter by selected run state, matching the latest run by default", async
() => {
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index e0d4572c4ee..9a7b4c4c09e 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -22,11 +22,7 @@ import { FiSidebar } from "react-icons/fi";
import { useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
-import type {
- DagRunState,
- DagSchedulingState,
- DAGWithLatestDagRunsResponse,
-} from "openapi/requests/types.gen";
+import type { DagRunState, DAGWithLatestDagRunsResponse } from
"openapi/requests/types.gen";
import { ActionBar, IconButton, Tooltip } from "src/system-components";
@@ -37,6 +33,10 @@ import type { CardDef } from
"src/components/DataTable/types";
import { SelectionProvider, useRowSelection } from
"src/components/DataTable/useRowSelection";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
import { ErrorAlert } from "src/components/ErrorAlert";
+import {
+ pausedStateFromSearchParams,
+ pausedStateToQuery,
+} from "src/components/FilterBar/filters/pausedStateParams";
import { SearchBar } from "src/components/SearchBar";
import { DAGS_LIST_DISPLAY_KEY, DAGS_LIST_SHOW_FOLDERS_KEY } from
"src/constants/localStorage";
@@ -70,8 +70,6 @@ const {
NEEDS_REVIEW,
OFFSET,
OWNERS,
- PAUSED,
- SCHEDULING_STATE,
TAGS,
TAGS_MATCH_MODE,
TEAMS,
@@ -113,8 +111,10 @@ export const DagsList = () => {
const multiTeamEnabled = Boolean(useConfig("multi_team"));
- const showPaused = searchParams.get(PAUSED);
- const schedulingState = searchParams.get(SCHEDULING_STATE) as
DagSchedulingState | null;
+ // Derived through the pill's own mapping so the query can never filter on
more than the pill
+ // shows. A legacy ``scheduling_state=active`` has no choice to represent
it, and would
+ // otherwise narrow the list with nothing on screen saying why.
+ const { paused, schedulingState } =
pausedStateToQuery(pausedStateFromSearchParams(searchParams));
const showFavorites = searchParams.get(FAVORITE);
const lastDagRunState = searchParams.get(LAST_DAG_RUN_STATE) as DagRunState;
@@ -182,18 +182,9 @@ export const DagsList = () => {
setSearchParams(searchParams);
};
- // No param means no paused filtering. usePausedDefault seeds the configured
default on load,
- // so an absent param after that is the user having removed the pill to see
every Dag.
- let paused = undefined;
let isFavorite = undefined;
let pendingHitl = undefined;
- if (showPaused === "true") {
- paused = true;
- } else if (showPaused === "false") {
- paused = false;
- }
-
if (showFavorites === "true") {
isFavorite = true;
} else if (showFavorites === "false") {
@@ -222,7 +213,7 @@ export const DagsList = () => {
paused,
pendingHitl,
relativeFilelocPrefix: selectedFolder,
- schedulingState: schedulingState ?? undefined,
+ schedulingState,
tags: selectedTags,
tagsMatchMode: selectedMatchMode,
teams: teams.length > 0 ? teams : undefined,
diff --git a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
index 4b70b9a7ab2..79d3651e272 100644
--- a/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
+++ b/airflow-core/src/airflow/ui/src/utils/useFiltersHandler.ts
@@ -106,7 +106,6 @@ export type FilterableSearchParamsKeys =
| SearchParamsKeys.RUN_ID_PATTERN
| SearchParamsKeys.RUN_STATE
| SearchParamsKeys.RUN_TYPE
- | SearchParamsKeys.SCHEDULING_STATE
| SearchParamsKeys.START_DATE_RANGE
| SearchParamsKeys.STATE
| SearchParamsKeys.SUBJECT_SEARCH