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

pierrejeambrun pushed a commit to branch v3-2-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-2-test by this push:
     new f92d22b803d [v3-2-test] Expose queueing/scheduled time in the Gantt 
Chart (#63372) (#65016)
f92d22b803d is described below

commit f92d22b803d0b2eea9e3adf28533026de261ec5d
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Apr 13 18:09:05 2026 +0200

    [v3-2-test] Expose queueing/scheduled time in the Gantt Chart (#63372) 
(#65016)
    
    * Expose queueing time in the Gantt Chart
    
    * Also expose scheduled_dttm in the Gantt Chart
    
    * Simplify Gantt tooltip and ensure minimum bar visibility for short 
segments
    
    * Null safety for dayjs calls and add tests for timing segments
    (cherry picked from commit cd851646fba29e89c848a19ee78c9ee8f81ad238)
    
    Co-authored-by: Saumyajit Chowdhury 
<[email protected]>
---
 .../api_fastapi/core_api/datamodels/ui/gantt.py    |  2 +
 .../api_fastapi/core_api/openapi/_private_ui.yaml  | 14 ++++
 .../api_fastapi/core_api/routes/ui/gantt.py        |  6 ++
 .../airflow/ui/openapi-gen/requests/schemas.gen.ts | 26 ++++++-
 .../airflow/ui/openapi-gen/requests/types.gen.ts   |  2 +
 .../ui/src/layouts/Details/Gantt/utils.test.ts     | 89 ++++++++++++++++++++++
 .../airflow/ui/src/layouts/Details/Gantt/utils.ts  | 50 ++++++++++--
 .../api_fastapi/core_api/routes/ui/test_gantt.py   | 24 ++++++
 8 files changed, 207 insertions(+), 6 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/gantt.py 
b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/gantt.py
index 57a96c8a0ad..3b74e84b47f 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/gantt.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/gantt.py
@@ -30,6 +30,8 @@ class GanttTaskInstance(BaseModel):
     task_display_name: str
     try_number: int
     state: TaskInstanceState | None
+    scheduled_dttm: datetime | None
+    queued_dttm: datetime | None
     start_date: datetime | None
     end_date: datetime | None
     is_group: bool = False
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 706cf64b492..87ed72c730b 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
@@ -2249,6 +2249,18 @@ components:
           anyOf:
           - $ref: '#/components/schemas/TaskInstanceState'
           - type: 'null'
+        scheduled_dttm:
+          anyOf:
+          - type: string
+            format: date-time
+          - type: 'null'
+          title: Scheduled Dttm
+        queued_dttm:
+          anyOf:
+          - type: string
+            format: date-time
+          - type: 'null'
+          title: Queued Dttm
         start_date:
           anyOf:
           - type: string
@@ -2275,6 +2287,8 @@ components:
       - task_display_name
       - try_number
       - state
+      - scheduled_dttm
+      - queued_dttm
       - start_date
       - end_date
       title: GanttTaskInstance
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 f33b12e6f7e..7807e3fd6bc 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
@@ -67,6 +67,8 @@ def get_gantt_data(
         TaskInstance.task_display_name.label("task_display_name"),  # type: 
ignore[attr-defined]
         TaskInstance.try_number.label("try_number"),
         TaskInstance.state.label("state"),
+        TaskInstance.scheduled_dttm.label("scheduled_dttm"),
+        TaskInstance.queued_dttm.label("queued_dttm"),
         TaskInstance.start_date.label("start_date"),
         TaskInstance.end_date.label("end_date"),
     ).where(
@@ -81,6 +83,8 @@ def get_gantt_data(
         TaskInstanceHistory.task_display_name.label("task_display_name"),
         TaskInstanceHistory.try_number.label("try_number"),
         TaskInstanceHistory.state.label("state"),
+        TaskInstanceHistory.scheduled_dttm.label("scheduled_dttm"),
+        TaskInstanceHistory.queued_dttm.label("queued_dttm"),
         TaskInstanceHistory.start_date.label("start_date"),
         TaskInstanceHistory.end_date.label("end_date"),
     ).where(
@@ -106,6 +110,8 @@ def get_gantt_data(
             task_display_name=row.task_display_name,
             try_number=row.try_number,
             state=row.state,
+            scheduled_dttm=row.scheduled_dttm,
+            queued_dttm=row.queued_dttm,
             start_date=row.start_date,
             end_date=row.end_date,
         )
diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts 
b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
index 356802aab9f..be72276746c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts
@@ -8028,6 +8028,30 @@ export const $GanttTaskInstance = {
                 }
             ]
         },
+        scheduled_dttm: {
+            anyOf: [
+                {
+                    type: 'string',
+                    format: 'date-time'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Scheduled Dttm'
+        },
+        queued_dttm: {
+            anyOf: [
+                {
+                    type: 'string',
+                    format: 'date-time'
+                },
+                {
+                    type: 'null'
+                }
+            ],
+            title: 'Queued Dttm'
+        },
         start_date: {
             anyOf: [
                 {
@@ -8064,7 +8088,7 @@ export const $GanttTaskInstance = {
         }
     },
     type: 'object',
-    required: ['task_id', 'task_display_name', 'try_number', 'state', 
'start_date', 'end_date'],
+    required: ['task_id', 'task_display_name', 'try_number', 'state', 
'scheduled_dttm', 'queued_dttm', 'start_date', 'end_date'],
     title: 'GanttTaskInstance',
     description: 'Task instance data for Gantt chart.'
 } as const;
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 1601e54b01a..36228389032 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
@@ -1973,6 +1973,8 @@ export type GanttTaskInstance = {
     task_display_name: string;
     try_number: number;
     state: TaskInstanceState | null;
+    scheduled_dttm: string | null;
+    queued_dttm: string | null;
     start_date: string | null;
     end_date: string | null;
     is_group?: boolean;
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
index b9fa6a491be..a0e61cc8579 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
@@ -1,3 +1,5 @@
+/* eslint-disable max-lines */
+
 /*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -166,6 +168,10 @@ describe("transformGanttData", () => {
           end_date: null,
           is_mapped: false,
           // eslint-disable-next-line unicorn/no-null
+          queued_dttm: null,
+          // eslint-disable-next-line unicorn/no-null
+          scheduled_dttm: null,
+          // eslint-disable-next-line unicorn/no-null
           start_date: null,
           // eslint-disable-next-line unicorn/no-null
           state: null,
@@ -189,6 +195,10 @@ describe("transformGanttData", () => {
           // eslint-disable-next-line unicorn/no-null
           end_date: null,
           is_mapped: false,
+          // eslint-disable-next-line unicorn/no-null
+          queued_dttm: null,
+          // eslint-disable-next-line unicorn/no-null
+          scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "running",
           task_display_name: "task_1",
@@ -237,6 +247,10 @@ describe("transformGanttData", () => {
         {
           end_date: "2024-03-14T10:05:00+00:00",
           is_mapped: false,
+          // eslint-disable-next-line unicorn/no-null
+          queued_dttm: null,
+          // eslint-disable-next-line unicorn/no-null
+          scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "success",
           task_display_name: "task_1",
@@ -258,4 +272,79 @@ describe("transformGanttData", () => {
     expect(Number.isNaN(start.valueOf())).toBe(false);
     expect(Number.isNaN(end.valueOf())).toBe(false);
   });
+
+  it("should produce 3 segments when scheduled_dttm and queued_dttm are 
present", () => {
+    const result = transformGanttData({
+      allTries: [
+        {
+          end_date: "2024-03-14T10:05:00+00:00",
+          is_mapped: false,
+          queued_dttm: "2024-03-14T09:59:00+00:00",
+          scheduled_dttm: "2024-03-14T09:58:00+00:00",
+          start_date: "2024-03-14T10:00:00+00:00",
+          state: "success",
+          task_display_name: "task_1",
+          task_id: "task_1",
+          try_number: 1,
+        },
+      ],
+      flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" 
}],
+      gridSummaries: [],
+    });
+
+    expect(result).toHaveLength(3);
+    expect(result[0]?.state).toBe("scheduled");
+    expect(result[1]?.state).toBe("queued");
+    expect(result[2]?.state).toBe("success");
+  });
+
+  it("should produce 2 segments when only queued_dttm is present", () => {
+    const result = transformGanttData({
+      allTries: [
+        {
+          end_date: "2024-03-14T10:05:00+00:00",
+          is_mapped: false,
+          queued_dttm: "2024-03-14T09:59:00+00:00",
+          // eslint-disable-next-line unicorn/no-null
+          scheduled_dttm: null,
+          start_date: "2024-03-14T10:00:00+00:00",
+          state: "success",
+          task_display_name: "task_1",
+          task_id: "task_1",
+          try_number: 1,
+        },
+      ],
+      flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" 
}],
+      gridSummaries: [],
+    });
+
+    expect(result).toHaveLength(2);
+    expect(result[0]?.state).toBe("queued");
+    expect(result[1]?.state).toBe("success");
+  });
+
+  it("should produce 1 segment when scheduled_dttm and queued_dttm are null", 
() => {
+    const result = transformGanttData({
+      allTries: [
+        {
+          end_date: "2024-03-14T10:05:00+00:00",
+          is_mapped: false,
+          // eslint-disable-next-line unicorn/no-null
+          queued_dttm: null,
+          // eslint-disable-next-line unicorn/no-null
+          scheduled_dttm: null,
+          start_date: "2024-03-14T10:00:00+00:00",
+          state: "success",
+          task_display_name: "task_1",
+          task_id: "task_1",
+          try_number: 1,
+        },
+      ],
+      flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" 
}],
+      gridSummaries: [],
+    });
+
+    expect(result).toHaveLength(1);
+    expect(result[0]?.state).toBe("success");
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
index fab1d1bcf77..621f22e8ab9 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
@@ -122,14 +122,47 @@ export const transformGanttData = ({
         if (tries && tries.length > 0) {
           return tries
             .filter((tryInstance) => tryInstance.start_date !== null)
-            .map((tryInstance) => {
+            .flatMap((tryInstance) => {
               const hasTaskRunning = isStatePending(tryInstance.state);
               const endTime =
                 hasTaskRunning || tryInstance.end_date === null
                   ? dayjs().toISOString()
                   : tryInstance.end_date;
-
-              return {
+              const items: Array<GanttDataItem> = [];
+
+              // Scheduled segment: from scheduled_dttm to queued_dttm (or 
start_date if no queued_dttm)
+              if (tryInstance.scheduled_dttm !== null) {
+                const scheduledEnd = tryInstance.queued_dttm ?? 
tryInstance.start_date ?? undefined;
+
+                items.push({
+                  isGroup: false,
+                  isMapped: tryInstance.is_mapped,
+                  state: "scheduled" as TaskInstanceState,
+                  taskId: tryInstance.task_id,
+                  tryNumber: tryInstance.try_number,
+                  x: [dayjs(tryInstance.scheduled_dttm).toISOString(), 
dayjs(scheduledEnd).toISOString()],
+                  y: tryInstance.task_display_name,
+                });
+              }
+
+              // Queue segment: from queued_dttm to start_date
+              if (tryInstance.queued_dttm !== null) {
+                items.push({
+                  isGroup: false,
+                  isMapped: tryInstance.is_mapped,
+                  state: "queued" as TaskInstanceState,
+                  taskId: tryInstance.task_id,
+                  tryNumber: tryInstance.try_number,
+                  x: [
+                    dayjs(tryInstance.queued_dttm).toISOString(),
+                    dayjs(tryInstance.start_date ?? undefined).toISOString(),
+                  ],
+                  y: tryInstance.task_display_name,
+                });
+              }
+
+              // Execution segment: from start_date to end_date
+              items.push({
                 isGroup: false,
                 isMapped: tryInstance.is_mapped,
                 state: tryInstance.state,
@@ -137,7 +170,9 @@ export const transformGanttData = ({
                 tryNumber: tryInstance.try_number,
                 x: [dayjs(tryInstance.start_date).toISOString(), 
dayjs(endTime).toISOString()],
                 y: tryInstance.task_display_name,
-              };
+              });
+
+              return items;
             });
         }
       }
@@ -259,6 +294,11 @@ export const createChartOptions = ({
       duration: 150,
       easing: "linear" as const,
     },
+    datasets: {
+      bar: {
+        minBarLength: 4,
+      },
+    },
     indexAxis: "y" as const,
     maintainAspectRatio: false,
     onClick: handleBarClick,
@@ -331,7 +371,7 @@ export const createChartOptions = ({
           label(tooltipItem: TooltipItem<"bar">) {
             const taskInstance = data[tooltipItem.dataIndex];
 
-            return `${translate("state")}: 
${translate(`states.${taskInstance?.state}`)}`;
+            return `${translate("state")}: 
${translate(`common:states.${taskInstance?.state}`)}`;
           },
         },
       },
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 162c82682af..0e2be9e277c 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
@@ -51,6 +51,8 @@ GANTT_TASK_1 = {
     "task_display_name": TASK_DISPLAY_NAME,
     "try_number": 1,
     "state": "success",
+    "scheduled_dttm": "2024-11-30T09:50:00Z",
+    "queued_dttm": "2024-11-30T09:55:00Z",
     "start_date": "2024-11-30T10:00:00Z",
     "end_date": "2024-11-30T10:05:00Z",
     "is_group": False,
@@ -62,6 +64,8 @@ GANTT_TASK_2 = {
     "task_display_name": TASK_DISPLAY_NAME_2,
     "try_number": 1,
     "state": "failed",
+    "scheduled_dttm": "2024-11-30T10:02:00Z",
+    "queued_dttm": "2024-11-30T10:03:00Z",
     "start_date": "2024-11-30T10:05:00Z",
     "end_date": "2024-11-30T10:10:00Z",
     "is_group": False,
@@ -73,6 +77,8 @@ GANTT_TASK_3 = {
     "task_display_name": TASK_DISPLAY_NAME_3,
     "try_number": 1,
     "state": "running",
+    "scheduled_dttm": None,
+    "queued_dttm": None,
     "start_date": "2024-11-30T10:10:00Z",
     "end_date": None,
     "is_group": False,
@@ -116,16 +122,22 @@ def setup(dag_maker, session=None):
         if ti.task_id == TASK_ID:
             ti.state = TaskInstanceState.SUCCESS
             ti.try_number = 1
+            ti.scheduled_dttm = pendulum.DateTime(2024, 11, 30, 9, 50, 0, 
tzinfo=pendulum.UTC)
+            ti.queued_dttm = pendulum.DateTime(2024, 11, 30, 9, 55, 0, 
tzinfo=pendulum.UTC)
             ti.start_date = pendulum.DateTime(2024, 11, 30, 10, 0, 0, 
tzinfo=pendulum.UTC)
             ti.end_date = pendulum.DateTime(2024, 11, 30, 10, 5, 0, 
tzinfo=pendulum.UTC)
         elif ti.task_id == TASK_ID_2:
             ti.state = TaskInstanceState.FAILED
             ti.try_number = 1
+            ti.scheduled_dttm = pendulum.DateTime(2024, 11, 30, 10, 2, 0, 
tzinfo=pendulum.UTC)
+            ti.queued_dttm = pendulum.DateTime(2024, 11, 30, 10, 3, 0, 
tzinfo=pendulum.UTC)
             ti.start_date = pendulum.DateTime(2024, 11, 30, 10, 5, 0, 
tzinfo=pendulum.UTC)
             ti.end_date = pendulum.DateTime(2024, 11, 30, 10, 10, 0, 
tzinfo=pendulum.UTC)
         elif ti.task_id == TASK_ID_3:
             ti.state = TaskInstanceState.RUNNING
             ti.try_number = 1
+            ti.scheduled_dttm = None
+            ti.queued_dttm = None
             ti.start_date = pendulum.DateTime(2024, 11, 30, 10, 10, 0, 
tzinfo=pendulum.UTC)
             ti.end_date = None
 
@@ -306,6 +318,18 @@ class TestGetGanttDataEndpoint:
         sorted_tis = sorted(task_instances, key=lambda x: (x["task_id"], 
x["try_number"]))
         assert task_instances == sorted_tis
 
+    def test_timing_fields_are_returned(self, test_client):
+        response = test_client.get(f"/gantt/{DAG_ID}/run_1")
+        assert response.status_code == 200
+        data = response.json()
+        tis = {ti["task_id"]: ti for ti in data["task_instances"]}
+        assert tis[TASK_ID]["scheduled_dttm"] == "2024-11-30T09:50:00Z"
+        assert tis[TASK_ID]["queued_dttm"] == "2024-11-30T09:55:00Z"
+        assert tis[TASK_ID_2]["scheduled_dttm"] == "2024-11-30T10:02:00Z"
+        assert tis[TASK_ID_2]["queued_dttm"] == "2024-11-30T10:03:00Z"
+        assert tis[TASK_ID_3]["scheduled_dttm"] is None
+        assert tis[TASK_ID_3]["queued_dttm"] is None
+
     def test_should_response_401(self, unauthenticated_test_client):
         response = unauthenticated_test_client.get(f"/gantt/{DAG_ID}/run_1")
         assert response.status_code == 401

Reply via email to