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 30d7663297b Add time range filtering to the Gantt view (#73555)
30d7663297b is described below
commit 30d7663297b6057f4c728349880f20d19dc339c3
Author: wiasliaw <[email protected]>
AuthorDate: Wed Oct 21 10:59:19 2026 +0800
Add time range filtering to the Gantt view (#73555)
* Add time range filtering to the Gantt endpoint
The Gantt view always receives every task instance try of a run, so the
UI cannot narrow the chart to the time window a user is inspecting
without fetching rows it will discard. Filtering in the database keeps
large runs cheap to browse and gives the frontend date-range filter a
backend to target. The existing NULL-aware range filters keep running
tasks (no end_date yet) visible inside the window, and an existing run
whose tries all fall outside the window now returns an empty 200
response instead of a 404.
* UI: Add time range filtering to the Gantt view
Large runs make the Gantt chart hard to read; users asked to narrow
it to a datetime window. An earlier attempt was rejected in review
for filtering client-side with date-only precision and a bespoke
date picker, so this exposes the existing Start/End Date range
filter pills on the Gantt view and passes the window to the Gantt
endpoint's time range query parameters, letting the backend do the
filtering with full datetime precision.
* Trim Gantt filter comments
Drop comments flagged in review as narrating the code.
---
.../api_fastapi/core_api/openapi/_private_ui.yaml | 72 +++++++++++
.../api_fastapi/core_api/routes/ui/gantt.py | 24 +++-
.../src/airflow/ui/openapi-gen/queries/common.ts | 12 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 20 +++-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 20 +++-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 20 +++-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 20 +++-
.../ui/openapi-gen/requests/services.gen.ts | 18 +++
.../airflow/ui/openapi-gen/requests/types.gen.ts | 8 ++
.../ui/src/layouts/Details/Gantt/Gantt.test.tsx | 133 +++++++++++++++++++++
.../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 9 +-
.../ui/src/layouts/Details/GridFilters.test.tsx | 79 ++++++++++++
.../airflow/ui/src/layouts/Details/GridFilters.tsx | 24 ++--
.../ui/src/layouts/Details/PanelButtons.tsx | 2 +-
.../api_fastapi/core_api/routes/ui/test_gantt.py | 121 +++++++++++++++++++
15 files changed, 558 insertions(+), 24 deletions(-)
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
index 847d6e8e04e..51320cabc1e 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
+++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml
@@ -2556,6 +2556,78 @@ paths:
schema:
type: string
title: Run Id
+ - name: start_date_gte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Gte
+ - name: start_date_gt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Gt
+ - name: start_date_lte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Lte
+ - name: start_date_lt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: Start Date Lt
+ - name: end_date_gte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Gte
+ - name: end_date_gt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Gt
+ - name: end_date_lte
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Lte
+ - name: end_date_lt
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ format: date-time
+ - type: 'null'
+ title: End Date Lt
responses:
'200':
description: Successful Response
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
index 9d125b5e934..29f2e01b4f6 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/gantt.py
@@ -17,17 +17,21 @@
from __future__ import annotations
+from typing import Annotated
+
from fastapi import Depends, HTTPException, status
from sqlalchemy import or_, select
from airflow.api_fastapi.auth.managers.models.resource_details import
DagAccessEntity
from airflow.api_fastapi.common.dagbag import DagBagDep
from airflow.api_fastapi.common.db.common import SessionDep
+from airflow.api_fastapi.common.parameters import RangeFilter,
datetime_range_filter_factory
from airflow.api_fastapi.common.router import AirflowRouter
from airflow.api_fastapi.core_api.datamodels.task_instances import
LoopIterationResponse
from airflow.api_fastapi.core_api.datamodels.ui.gantt import GanttResponse,
GanttTaskInstance
from airflow.api_fastapi.core_api.openapi.exceptions import
create_openapi_http_exception_doc
from airflow.api_fastapi.core_api.security import requires_access_dag
+from airflow.models.dagrun import DagRun
from airflow.models.task_coordinates import TaskCoordinateResolver,
public_map_index_expression
from airflow.models.taskinstance import TaskInstance
from airflow.utils.state import TaskInstanceState
@@ -60,6 +64,10 @@ gantt_router = AirflowRouter(prefix="/gantt", tags=["Gantt"])
def get_gantt_data(
dag_id: str,
run_id: str,
+ start_date_range: Annotated[
+ RangeFilter, Depends(datetime_range_filter_factory("start_date",
TaskInstance))
+ ],
+ end_date_range: Annotated[RangeFilter,
Depends(datetime_range_filter_factory("end_date", TaskInstance))],
session: SessionDep,
dag_bag: DagBagDep,
) -> GanttResponse:
@@ -97,14 +105,22 @@ def get_gantt_data(
# loses each earlier try the moment a clear archives it -- including
the attempt the
# filter above drops the pending retry in favour of.
).execution_options(include_all_attempts=True)
+ query = start_date_range.to_orm(query)
+ query = end_date_range.to_orm(query)
results = session.execute(query).fetchall()
if not results:
- raise HTTPException(
- status.HTTP_404_NOT_FOUND,
- f"No task instances for dag_id={dag_id} run_id={run_id}",
- )
+ filtered_existing_run = (
+ start_date_range.is_active() or end_date_range.is_active()
+ ) and session.scalar(
+ select(DagRun.run_id).where(DagRun.dag_id == dag_id, DagRun.run_id
== run_id)
+ ) is not None
+ if not filtered_existing_run:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ f"No task instances for dag_id={dag_id} run_id={run_id}",
+ )
resolver = TaskCoordinateResolver(dag_bag, session)
resolver.prefetch_regions(results)
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
index 22ca2c78ed2..8ade19b873f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -1249,10 +1249,18 @@ export const UseGridServiceGetLoopHistoryKeyFn = ({
dagId, groupId, limit }: {
export type GanttServiceGetGanttDataDefaultResponse =
Awaited<ReturnType<typeof GanttService.getGanttData>>;
export type GanttServiceGetGanttDataQueryResult<TData =
GanttServiceGetGanttDataDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useGanttServiceGetGanttDataKey = "GanttServiceGetGanttData";
-export const UseGanttServiceGetGanttDataKeyFn = ({ dagId, runId }: {
+export const UseGanttServiceGetGanttDataKeyFn = ({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runId, startDateGt, startDateGte,
startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runId: string;
-}, queryKey?: Array<unknown>) => [useGanttServiceGetGanttDataKey, ...(queryKey
?? [{ dagId, runId }])];
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: Array<unknown>) => [useGanttServiceGetGanttDataKey, ...(queryKey
?? [{ dagId, endDateGt, endDateGte, endDateLt, endDateLte, runId, startDateGt,
startDateGte, startDateLt, startDateLte }])];
export type CalendarServiceGetCalendarDefaultResponse =
Awaited<ReturnType<typeof CalendarService.getCalendar>>;
export type CalendarServiceGetCalendarQueryResult<TData =
CalendarServiceGetCalendarDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useCalendarServiceGetCalendarKey = "CalendarServiceGetCalendar";
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
index bb6c50f3507..26726a5bc9f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -2476,13 +2476,29 @@ export const ensureUseGridServiceGetLoopHistoryData =
(queryClient: QueryClient,
* @param data The data for the request.
* @param data.dagId
* @param data.runId
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns GanttResponse Successful Response
* @throws ApiError
*/
-export const ensureUseGanttServiceGetGanttDataData = (queryClient:
QueryClient, { dagId, runId }: {
+export const ensureUseGanttServiceGetGanttDataData = (queryClient:
QueryClient, { dagId, endDateGt, endDateGte, endDateLt, endDateLte, runId,
startDateGt, startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runId: string;
-}) => queryClient.ensureQueryData({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, runId }), queryFn: () =>
GanttService.getGanttData({ dagId, runId }) });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}) => queryClient.ensureQueryData({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runId, startDateGt, startDateGte, startDateLt,
startDateLte }), queryFn: () => GanttService.getGanttData({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runId, startDateGt, startDateGte,
startDateLt, startDateLte }) });
/**
* Get Calendar
* Get calendar data for a Dag including historical and planned Dag runs.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
index 661826be84c..d8feaacbb9b 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -2476,13 +2476,29 @@ export const prefetchUseGridServiceGetLoopHistory =
(queryClient: QueryClient, {
* @param data The data for the request.
* @param data.dagId
* @param data.runId
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns GanttResponse Successful Response
* @throws ApiError
*/
-export const prefetchUseGanttServiceGetGanttData = (queryClient: QueryClient,
{ dagId, runId }: {
+export const prefetchUseGanttServiceGetGanttData = (queryClient: QueryClient,
{ dagId, endDateGt, endDateGte, endDateLt, endDateLte, runId, startDateGt,
startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runId: string;
-}) => queryClient.prefetchQuery({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, runId }), queryFn: () =>
GanttService.getGanttData({ dagId, runId }) });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}) => queryClient.prefetchQuery({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runId, startDateGt, startDateGte, startDateLt,
startDateLte }), queryFn: () => GanttService.getGanttData({ dagId, endDateGt,
endDateGte, endDateLt, endDateLte, runId, startDateGt, startDateGte,
startDateLt, startDateLte }) });
/**
* Get Calendar
* Get calendar data for a Dag including historical and planned Dag runs.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
index d620a72ace7..bbac95f6116 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -2476,13 +2476,29 @@ export const useGridServiceGetLoopHistory = <TData =
Common.GridServiceGetLoopHi
* @param data The data for the request.
* @param data.dagId
* @param data.runId
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns GanttResponse Successful Response
* @throws ApiError
*/
-export const useGanttServiceGetGanttData = <TData =
Common.GanttServiceGetGanttDataDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>({ dagId, runId }: {
+export const useGanttServiceGetGanttData = <TData =
Common.GanttServiceGetGanttDataDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runId, startDateGt, startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runId: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, runId }, queryKey), queryFn:
() => GanttService.getGanttData({ dagId, runId }) as TData, ...options });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runId, startDateGt, startDateGte, startDateLt,
startDateLte }, queryKey), queryFn: () => GanttService.getGanttData({ dagId,
endDateGt, endDateGte, endDateLt, endDateLte, runId, startDateGt, startDateGte,
startDateLt, startDateLte }) as TData, ...options });
/**
* Get Calendar
* Get calendar data for a Dag including historical and planned Dag runs.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
index 5e7a0cef977..162f495e6ba 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -2476,13 +2476,29 @@ export const useGridServiceGetLoopHistorySuspense =
<TData = Common.GridServiceG
* @param data The data for the request.
* @param data.dagId
* @param data.runId
+* @param data.startDateGte
+* @param data.startDateGt
+* @param data.startDateLte
+* @param data.startDateLt
+* @param data.endDateGte
+* @param data.endDateGt
+* @param data.endDateLte
+* @param data.endDateLt
* @returns GanttResponse Successful Response
* @throws ApiError
*/
-export const useGanttServiceGetGanttDataSuspense = <TData =
Common.GanttServiceGetGanttDataDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>({ dagId, runId }: {
+export const useGanttServiceGetGanttDataSuspense = <TData =
Common.GanttServiceGetGanttDataDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>({ dagId, endDateGt, endDateGte, endDateLt,
endDateLte, runId, startDateGt, startDateGte, startDateLt, startDateLte }: {
dagId: string;
+ endDateGt?: string;
+ endDateGte?: string;
+ endDateLt?: string;
+ endDateLte?: string;
runId: string;
-}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, runId }, queryKey), queryFn:
() => GanttService.getGanttData({ dagId, runId }) as TData, ...options });
+ startDateGt?: string;
+ startDateGte?: string;
+ startDateLt?: string;
+ startDateLte?: string;
+}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseGanttServiceGetGanttDataKeyFn({ dagId, endDateGt, endDateGte,
endDateLt, endDateLte, runId, startDateGt, startDateGte, startDateLt,
startDateLte }, queryKey), queryFn: () => GanttService.getGanttData({ dagId,
endDateGt, endDateGte, endDateLt, endDateLte, runId, startDateGt, startDateGte,
startDateLt, startDateLte }) as TData, ...options });
/**
* Get Calendar
* Get calendar data for a Dag including historical and planned Dag runs.
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
index 369e8e386f0..b82d12631cc 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts
@@ -5844,6 +5844,14 @@ export class GanttService {
* @param data The data for the request.
* @param data.dagId
* @param data.runId
+ * @param data.startDateGte
+ * @param data.startDateGt
+ * @param data.startDateLte
+ * @param data.startDateLt
+ * @param data.endDateGte
+ * @param data.endDateGt
+ * @param data.endDateLte
+ * @param data.endDateLt
* @returns GanttResponse Successful Response
* @throws ApiError
*/
@@ -5855,6 +5863,16 @@ export class GanttService {
dag_id: data.dagId,
run_id: data.runId
},
+ query: {
+ start_date_gte: data.startDateGte,
+ start_date_gt: data.startDateGt,
+ start_date_lte: data.startDateLte,
+ start_date_lt: data.startDateLt,
+ end_date_gte: data.endDateGte,
+ end_date_gt: data.endDateGt,
+ end_date_lte: data.endDateLte,
+ end_date_lt: data.endDateLt
+ },
errors: {
404: 'Not Found',
422: 'Validation Error'
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
index 59af66a1f7a..a55f3edac08 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts
@@ -5613,7 +5613,15 @@ export type GetLoopHistoryResponse = LoopHistoryResponse;
export type GetGanttDataData = {
dagId: string;
+ endDateGt?: string | null;
+ endDateGte?: string | null;
+ endDateLt?: string | null;
+ endDateLte?: string | null;
runId: string;
+ startDateGt?: string | null;
+ startDateGte?: string | null;
+ startDateLt?: string | null;
+ startDateLte?: string | null;
};
export type GetGanttDataResponse = GanttResponse;
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
new file mode 100644
index 00000000000..f952d63043f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.test.tsx
@@ -0,0 +1,133 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type * as OpenapiQueries from "openapi/queries";
+
+import { TimezoneProvider } from "src/context/timezone";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Gantt } from "./Gantt";
+
+const { mockUseGanttServiceGetGanttData } = vi.hoisted(() => ({
+ mockUseGanttServiceGetGanttData: vi.fn(() => ({ data: undefined, isLoading:
false })),
+}));
+
+vi.mock("openapi/queries", async (importOriginal) => ({
+ ...(await importOriginal<typeof OpenapiQueries>()),
+ useGanttServiceGetGanttData: mockUseGanttServiceGetGanttData,
+}));
+
+vi.mock("src/queries/useGridRuns", () => ({
+ useGridRuns: () => ({
+ data: [
+ {
+ end_date: "2024-03-14T10:10:00Z",
+ run_after: "2024-03-14T10:00:00Z",
+ run_id: "run_1",
+ start_date: "2024-03-14T10:00:00Z",
+ state: "success",
+ },
+ ],
+ isLoading: false,
+ }),
+}));
+
+vi.mock("src/queries/useGridStructure", () => ({
+ useGridStructure: () => ({ data: [], isLoading: false }),
+}));
+
+vi.mock("src/queries/useGridTISummaries", () => ({
+ useGridTiSummariesStream: () => ({
+ summariesByRunId: new Map([["run_1", { task_instances: [] }]]),
+ }),
+}));
+
+vi.mock("src/context/groups", () => ({
+ useGroups: () => ({ openGroupIds: [], toggleGroupId: vi.fn() }),
+}));
+
+const createWrapper =
+ (initialEntries: Array<string>) =>
+ ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={initialEntries}>
+ <TimezoneProvider>
+ <Routes>
+ <Route element={children} path="/dags/:dagId/runs/:runId" />
+ </Routes>
+ </TimezoneProvider>
+ </MemoryRouter>
+ </BaseWrapper>
+ );
+
+beforeEach(() => {
+ mockUseGanttServiceGetGanttData.mockClear();
+});
+
+afterEach(cleanup);
+
+describe("Gantt time range filter params", () => {
+ it("passes the URL date window to the backend query", () => {
+ render(<Gantt limit={10} />, {
+ wrapper: createWrapper([
+ "/dags/test_dag/runs/run_1" +
+
"?start_date_gte=2024-03-14T10:00:00Z&start_date_lte=2024-03-14T10:05:00Z" +
+
"&end_date_gte=2024-03-14T10:01:00Z&end_date_lte=2024-03-14T10:10:00Z",
+ ]),
+ });
+
+ expect(mockUseGanttServiceGetGanttData).toHaveBeenCalledWith(
+ {
+ dagId: "test_dag",
+ endDateGte: "2024-03-14T10:01:00Z",
+ endDateLte: "2024-03-14T10:10:00Z",
+ runId: "run_1",
+ startDateGte: "2024-03-14T10:00:00Z",
+ startDateLte: "2024-03-14T10:05:00Z",
+ },
+ undefined,
+ expect.anything(),
+ );
+ });
+
+ it("requests the full run when no date window is set", () => {
+ render(<Gantt limit={10} />, {
+ wrapper: createWrapper(["/dags/test_dag/runs/run_1"]),
+ });
+
+ expect(mockUseGanttServiceGetGanttData).toHaveBeenCalledWith(
+ {
+ dagId: "test_dag",
+ endDateGte: undefined,
+ endDateLte: undefined,
+ runId: "run_1",
+ startDateGte: undefined,
+ startDateLte: undefined,
+ },
+ undefined,
+ expect.anything(),
+ );
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
index 1eb2f3fe0ce..e755789e415 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
@@ -32,6 +32,7 @@ import {
} from "src/layouts/Details/Grid/constants";
import { flattenNodes } from "src/layouts/Details/Grid/utils";
+import { SearchParamsKeys } from "src/constants/searchParams";
import { useGroups } from "src/context/groups";
import { useTimezone } from "src/context/timezone";
import { NavigationModes, useNavigation } from "src/hooks/navigation";
@@ -84,6 +85,12 @@ export const Gantt = ({
const depthParam = searchParams.get("depth");
const depth = depthParam !== null && depthParam !== "" ?
parseInt(depthParam, 10) : undefined;
+ // Task-level time window, written by the Start/End Date pills in
GridFilters.
+ const startDateGte = searchParams.get(SearchParamsKeys.START_DATE_GTE) ??
undefined;
+ const startDateLte = searchParams.get(SearchParamsKeys.START_DATE_LTE) ??
undefined;
+ const endDateGte = searchParams.get(SearchParamsKeys.END_DATE_GTE) ??
undefined;
+ const endDateLte = searchParams.get(SearchParamsKeys.END_DATE_LTE) ??
undefined;
+
const { data: gridRuns, isLoading: runsLoading } = useGridRuns({
dagRunState,
limit,
@@ -117,7 +124,7 @@ export const Gantt = ({
const summariesLoading = Boolean(runId && selectedRun &&
!summariesByRunId.has(runId));
const { data: ganttData, isLoading: ganttLoading } =
useGanttServiceGetGanttData(
- { dagId, runId },
+ { dagId, endDateGte, endDateLte, runId, startDateGte, startDateLte },
undefined,
{
enabled: Boolean(dagId) && Boolean(runId) && Boolean(selectedRun),
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.test.tsx
new file mode 100644
index 00000000000..22cb4352c3c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.test.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 { PropsWithChildren } from "react";
+
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import dayjs from "dayjs";
+import timezone from "dayjs/plugin/timezone";
+import utc from "dayjs/plugin/utc";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { TimezoneProvider } from "src/context/timezone";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { GridFilters } from "./GridFilters";
+
+dayjs.extend(timezone);
+dayjs.extend(utc);
+
+const createWrapper =
+ (initialEntries: Array<string> = ["/dags/test_dag/runs/run_1"]) =>
+ ({ children }: PropsWithChildren) => (
+ <BaseWrapper>
+ <MemoryRouter initialEntries={initialEntries}>
+ <TimezoneProvider>{children}</TimezoneProvider>
+ </MemoryRouter>
+ </BaseWrapper>
+ );
+
+afterEach(cleanup);
+
+describe("GridFilters gantt date filters", () => {
+ it("offers the start and end date filters when enabled", async () => {
+ render(<GridFilters showGanttDateFilters />, { wrapper: createWrapper() });
+
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+
+ expect(await
screen.findByTestId("add-filter-start_date_range")).toBeInTheDocument();
+
expect(screen.getByTestId("add-filter-end_date_range")).toBeInTheDocument();
+ });
+
+ it("does not offer the date filters by default", async () => {
+ render(<GridFilters />, { wrapper: createWrapper() });
+
+ fireEvent.click(screen.getByTestId("add-filter-button"));
+
+ expect(await
screen.findByTestId("add-filter-run_id_pattern")).toBeInTheDocument();
+
expect(screen.queryByTestId("add-filter-start_date_range")).not.toBeInTheDocument();
+
expect(screen.queryByTestId("add-filter-end_date_range")).not.toBeInTheDocument();
+ });
+
+ it("restores active date filters from the URL", () => {
+ render(<GridFilters showGanttDateFilters />, {
+ wrapper: createWrapper([
+
"/dags/test_dag/runs/run_1?start_date_gte=2024-03-14T10:00:00Z&end_date_lte=2024-03-14T11:00:00Z",
+ ]),
+ });
+
+ expect(screen.getByTestId("start_date_range-pill")).toBeInTheDocument();
+ expect(screen.getByTestId("end_date_range-pill")).toBeInTheDocument();
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.tsx
index e096083f523..3b52c5538ac 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/GridFilters.tsx
@@ -21,15 +21,23 @@ import { FilterBar } from "src/components/FilterBar";
import { SearchParamsKeys } from "src/constants/searchParams";
import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils";
-const searchParamKeys: Array<FilterableSearchParamsKeys> = [
- SearchParamsKeys.RUN_ID_PATTERN,
- SearchParamsKeys.STATE,
- SearchParamsKeys.RUN_TYPE,
- SearchParamsKeys.TRIGGERING_USER_NAME_PATTERN,
- SearchParamsKeys.RUN_AFTER_RANGE,
-];
+type Props = {
+ readonly showGanttDateFilters?: boolean;
+};
+
+export const GridFilters = ({ showGanttDateFilters = false }: Props) => {
+ const searchParamKeys: Array<FilterableSearchParamsKeys> = [
+ SearchParamsKeys.RUN_ID_PATTERN,
+ SearchParamsKeys.STATE,
+ SearchParamsKeys.RUN_TYPE,
+ SearchParamsKeys.TRIGGERING_USER_NAME_PATTERN,
+ SearchParamsKeys.RUN_AFTER_RANGE,
+ ];
+
+ if (showGanttDateFilters) {
+ searchParamKeys.push(SearchParamsKeys.START_DATE_RANGE,
SearchParamsKeys.END_DATE_RANGE);
+ }
-export const GridFilters = () => {
const { filterConfigs, handleFiltersChange, initialValues } =
useFiltersHandler(searchParamKeys);
return (
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index dd990c31809..445bc0c976e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -260,7 +260,7 @@ export const PanelButtons = ({
{dagView !== "graph" && (
<Flex justifyContent="space-between" mt={2}>
- <GridFilters />
+ <GridFilters showGanttDateFilters={dagView === "gantt"} />
</Flex>
)}
</Box>
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_gantt.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_gantt.py
index 78ee974b5f5..96083a31b8e 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_gantt.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_gantt.py
@@ -22,6 +22,7 @@ from unittest.mock import ANY
import pendulum
import pytest
+from sqlalchemy import select
from sqlalchemy.orm import Session
from airflow._shared.timezones import timezone
@@ -427,3 +428,123 @@ class TestGetGanttDataEndpoint:
with assert_queries_count(3):
response = test_client.get(f"/gantt/{dag_id}/{run_id}")
assert response.status_code == 404
+
+ @pytest.mark.parametrize(
+ ("params", "expected_task_ids"),
+ [
+ pytest.param(
+ {"start_date_gte": "2024-11-30T10:04:00Z"},
+ ["task2", "task3"],
+ id="start_date_gte_excludes_earlier",
+ ),
+ pytest.param(
+ {"start_date_lte": "2024-11-30T10:04:00Z"},
+ ["task"],
+ id="start_date_lte_excludes_later",
+ ),
+ pytest.param(
+ {"end_date_gte": "2024-11-30T10:07:00Z"},
+ ["task2", "task3"],
+ id="end_date_gte_null_end_date_included",
+ ),
+ pytest.param(
+ {"end_date_lte": "2024-11-30T10:06:00Z"},
+ ["task"],
+ id="end_date_lte_running_task_excluded",
+ ),
+ pytest.param(
+ {"start_date_gte": "2024-11-30T10:04:00Z", "end_date_lte":
"2124-01-01T00:00:00Z"},
+ ["task2", "task3"],
+ id="combined_start_and_end_filters",
+ ),
+ ],
+ )
+ def test_time_range_filters(self, test_client, params, expected_task_ids):
+ with assert_queries_count(3):
+ response = test_client.get(f"/gantt/{DAG_ID}/run_1", params=params)
+ assert response.status_code == 200
+ data = response.json()
+ actual_task_ids = sorted(ti["task_id"] for ti in
data["task_instances"])
+ assert actual_task_ids == expected_task_ids
+
+ def test_null_start_date_excluded_by_past_lte(self, test_client, session):
+ ti = session.scalars(
+ select(TaskInstance).where(
+ TaskInstance.dag_id == DAG_ID,
+ TaskInstance.run_id == "run_1",
+ TaskInstance.task_id == TASK_ID_3,
+ )
+ ).one()
+ ti.state = None
+ ti.start_date = None
+ session.commit()
+
+ response = test_client.get(
+ f"/gantt/{DAG_ID}/run_1", params={"start_date_lte":
"2024-11-30T10:04:00Z"}
+ )
+ assert response.status_code == 200
+ actual_task_ids = {ti["task_id"] for ti in
response.json()["task_instances"]}
+ assert TASK_ID_3 not in actual_task_ids
+
+ @pytest.mark.parametrize(
+ ("params", "expected_tries"),
+ [
+ pytest.param(
+ {"start_date_lte": "2024-11-30T10:30:00Z"},
+ [1],
+ id="window_keeps_archived_try_drops_current",
+ ),
+ pytest.param(
+ {"start_date_gte": "2024-11-30T10:30:00Z"},
+ [2],
+ id="window_keeps_current_try_drops_archived",
+ ),
+ ],
+ )
+ def test_archived_attempts_are_filtered(self, test_client, dag_maker,
session, params, expected_tries):
+ with dag_maker("gantt_filter_cleared", serialized=True):
+ EmptyOperator(task_id="work")
+ dr = dag_maker.create_dagrun()
+ first = next(ti for ti in dr.task_instances if ti.task_id == "work")
+ first.try_number = 1
+ first.state = TaskInstanceState.SUCCESS
+ first.start_date = pendulum.DateTime(2024, 11, 30, 10, 0, 0,
tzinfo=pendulum.UTC)
+ first.end_date = pendulum.DateTime(2024, 11, 30, 10, 5, 0,
tzinfo=pendulum.UTC)
+ session.flush()
+ first.archive(reason="cleared", session=session)
+ retry = TaskInstance(
+ task=dag_maker.serialized_dag.get_task("work"),
+ run_id=dr.run_id,
+ dag_version_id=first.dag_version_id,
+ region_id=first.region_id,
+ region_index=first.region_index,
+ )
+ retry.try_number = 2
+ retry.state = TaskInstanceState.SUCCESS
+ retry.start_date = pendulum.DateTime(2024, 11, 30, 11, 0, 0,
tzinfo=pendulum.UTC)
+ retry.end_date = pendulum.DateTime(2024, 11, 30, 11, 5, 0,
tzinfo=pendulum.UTC)
+ session.add(retry)
+ session.commit()
+
+ response = test_client.get(f"/gantt/{dr.dag_id}/{dr.run_id}",
params=params)
+
+ assert response.status_code == 200, response.text
+ assert [row["try_number"] for row in
response.json()["task_instances"]] == expected_tries
+
+ def test_filtered_out_existing_run_returns_empty_200(self, test_client):
+ with assert_queries_count(4):
+ response = test_client.get(
+ f"/gantt/{DAG_ID}/run_1", params={"start_date_gte":
"2025-01-01T00:00:00Z"}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["dag_id"] == DAG_ID
+ assert data["run_id"] == "run_1"
+ assert data["task_instances"] == []
+
+ def test_filtered_missing_run_still_404(self, test_client):
+ with assert_queries_count(4):
+ response = test_client.get(
+ f"/gantt/{DAG_ID}/invalid_run", params={"start_date_gte":
"2025-01-01T00:00:00Z"}
+ )
+ assert response.status_code == 404