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