From 6e07a1c54f54787861a154ce0504a22af6352d93 Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Mon, 15 Jun 2026 16:52:49 +0530 Subject: [PATCH 1/2] UI: Fix Gantt tooltip showing wrong end date on queued/scheduled bars The tooltip on the queued and scheduled segments of a Gantt bar reported the end of that segment (e.g. the moment queueing ended) as the task's End Date, instead of the task's actual end. This was a regression from the start-date fix in #68176, which made the Start Date consistent across segments but left the End Date per-segment. Carry the task's effective end (end_date, or "now" while running) on every segment of a try as `end_when`, mirroring `start_when`, and use it for the tooltip's End Date so all segments of a try report the same start and end. closes: #68174 --- .../layouts/Details/Gantt/GanttTimeline.tsx | 2 +- .../src/layouts/Details/Gantt/utils.test.ts | 59 +++++++++++++++++++ .../ui/src/layouts/Details/Gantt/utils.ts | 20 ++++--- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx index 43c28dba56b52..940a80854e29b 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx @@ -85,7 +85,7 @@ const toTooltipSummary = ( return { child_states: null, - max_end_date: dayjs(segment.x[1]).toISOString(), + max_end_date: segment.end_when ?? dayjs(segment.x[1]).toISOString(), min_start_date: segment.start_when ?? dayjs(segment.x[0]).toISOString(), state: segment.state ?? null, task_display_name: segment.y, 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 d725dfc8be60d..c2a99132587ad 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 @@ -237,6 +237,65 @@ describe("transformGanttData", () => { expect(result[2]?.state).toBe("success"); }); + it("carries the task's actual start and end on every segment of the try", () => { + 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); + // The scheduled, queued, and execution bars all report the task's real start_date/end_date so + // the tooltip is consistent no matter which segment is hovered (regression from #68174). + const expectedEnd = new Date("2024-03-14T10:05:00+00:00").toISOString(); + + for (const segment of result) { + expect(segment.start_when).toBe("2024-03-14T10:00:00+00:00"); + expect(segment.end_when).toBe(expectedEnd); + } + }); + + it("uses the current time as end_when on every segment while the task is still running", () => { + const result = transformGanttData({ + allTries: [ + { + end_date: null, + is_mapped: false, + queued_dttm: "2024-03-14T09:59:00+00:00", + scheduled_dttm: null, + start_date: "2024-03-14T10:00:00+00:00", + state: "running", + 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: [], + }); + + // Queued + execution bars, both reporting the same (running) end so the tooltip is consistent. + expect(result.length).toBeGreaterThan(0); + const [firstEnd] = result.map((segment) => segment.end_when); + + for (const segment of result) { + expect(segment.end_when).toBe(firstEnd); + expect(segment.end_when).not.toBeUndefined(); + } + }); + it("produces 2 segments when only queued_dttm is present", () => { const result = transformGanttData({ allTries: [ 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 9502cd31e7843..70885649da06f 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 @@ -28,6 +28,8 @@ import { renderDuration } from "src/utils/datetimeUtils"; import { buildTaskInstanceUrl } from "src/utils/links"; export type GanttDataItem = { + /** Effective task end (end_date, or "now" while running) — consistent across all segments of the same try. */ + end_when?: string | null; isGroup?: boolean | null; isMapped?: boolean | null; /** Source try times for tooltips (matches TaskInstance `*_when` fields). */ @@ -135,13 +137,6 @@ export const transformGanttData = ({ const queuedMs = queuedDttm === null ? undefined : dayjs(queuedDttm).valueOf(); const scheduledMs = scheduledDttm === null ? undefined : dayjs(scheduledDttm).valueOf(); - // Include scheduled/queued/start times in tooltip data whenever the timestamps exist. - const tryWhenForTooltip = { - ...(scheduledMs === undefined ? {} : { scheduled_when: scheduledDttm }), - ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }), - ...(startDate === null ? {} : { start_when: startDate }), - }; - let endMs: number; if (hasTaskRunning) { @@ -152,6 +147,17 @@ export const transformGanttData = ({ endMs = dayjs(endDate).valueOf(); } + // Include scheduled/queued/start/end times in tooltip data whenever the timestamps exist. + // start_when/end_when are carried on every segment of a try so the tooltip reports the + // task's actual start and end on the scheduled and queued bars too, not just the + // execution bar's own bounds. + const tryWhenForTooltip = { + ...(scheduledMs === undefined ? {} : { scheduled_when: scheduledDttm }), + ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }), + ...(startDate === null ? {} : { start_when: startDate }), + ...(startDate === null && !hasTaskRunning ? {} : { end_when: dayjs(endMs).toISOString() }), + }; + if (scheduledMs !== undefined) { const scheduledEndMs = queuedMs ?? startMs ?? (hasTaskRunning || tryRow.state === "scheduled" ? Date.now() : endMs); From 9f816931b5a0943e2ab408abba5760da963fd9f4 Mon Sep 17 00:00:00 2001 From: Aaryan Mahajan Date: Thu, 25 Jun 2026 23:38:35 +0530 Subject: [PATCH 2/2] UI: Make end_when symmetric with start_when in Gantt tooltip data Address review: derive a single effectiveEndDate (the raw end_date, or "now" while a started task is still running) and gate end_when on it the same way start_when is gated on start_date. Finished tasks now keep the API's raw end_date string instead of a re-serialized dayjs timestamp, and the tryWhenForTooltip block no longer depends on endMs ordering. Pin the running-task test to a fixed "now" via fake timers. --- .../src/layouts/Details/Gantt/utils.test.ts | 67 ++++++++++--------- .../ui/src/layouts/Details/Gantt/utils.ts | 28 +++++--- 2 files changed, 53 insertions(+), 42 deletions(-) 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 c2a99132587ad..f39c751d50d81 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 @@ -17,7 +17,7 @@ * under the License. */ import dayjs from "dayjs"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen"; import type { GridTask } from "src/layouts/Details/Grid/utils"; @@ -257,42 +257,47 @@ describe("transformGanttData", () => { }); expect(result).toHaveLength(3); - // The scheduled, queued, and execution bars all report the task's real start_date/end_date so - // the tooltip is consistent no matter which segment is hovered (regression from #68174). - const expectedEnd = new Date("2024-03-14T10:05:00+00:00").toISOString(); - + // The scheduled, queued, and execution bars all report the task's real start_date/end_date + // (the raw API strings) so the tooltip is consistent no matter which segment is hovered + // (regression from #68174). for (const segment of result) { expect(segment.start_when).toBe("2024-03-14T10:00:00+00:00"); - expect(segment.end_when).toBe(expectedEnd); + expect(segment.end_when).toBe("2024-03-14T10:05:00+00:00"); } }); it("uses the current time as end_when on every segment while the task is still running", () => { - const result = transformGanttData({ - allTries: [ - { - end_date: null, - is_mapped: false, - queued_dttm: "2024-03-14T09:59:00+00:00", - scheduled_dttm: null, - start_date: "2024-03-14T10:00:00+00:00", - state: "running", - 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: [], - }); - - // Queued + execution bars, both reporting the same (running) end so the tooltip is consistent. - expect(result.length).toBeGreaterThan(0); - const [firstEnd] = result.map((segment) => segment.end_when); - - for (const segment of result) { - expect(segment.end_when).toBe(firstEnd); - expect(segment.end_when).not.toBeUndefined(); + const now = new Date("2024-03-14T10:30:00.000Z"); + + vi.useFakeTimers(); + vi.setSystemTime(now); + + try { + const result = transformGanttData({ + allTries: [ + { + end_date: null, + is_mapped: false, + queued_dttm: "2024-03-14T09:59:00+00:00", + scheduled_dttm: null, + start_date: "2024-03-14T10:00:00+00:00", + state: "running", + 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: [], + }); + + // Queued + execution bars, both reporting the same "now" end so the tooltip is consistent. + expect(result.length).toBeGreaterThan(0); + for (const segment of result) { + expect(segment.end_when).toBe(now.toISOString()); + } + } finally { + vi.useRealTimers(); } }); 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 70885649da06f..59e19214ca6fa 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 @@ -137,6 +137,23 @@ export const transformGanttData = ({ const queuedMs = queuedDttm === null ? undefined : dayjs(queuedDttm).valueOf(); const scheduledMs = scheduledDttm === null ? undefined : dayjs(scheduledDttm).valueOf(); + // Effective task end for tooltips: the real end_date, or "now" while a started task + // is still running. Mirrors startDate/start_when so end_when stays on the same scale — + // both are skipped together for a not-yet-started task and fall back to the bar's own + // bounds. + const effectiveEndDate = + endDate ?? (hasTaskRunning && startDate !== null ? new Date().toISOString() : null); + + // Carry scheduled/queued/start/end times on every segment of a try so the tooltip + // reports the task's actual start and end on the scheduled and queued bars too, not + // just the execution bar's own bounds. + const tryWhenForTooltip = { + ...(scheduledMs === undefined ? {} : { scheduled_when: scheduledDttm }), + ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }), + ...(startDate === null ? {} : { start_when: startDate }), + ...(effectiveEndDate === null ? {} : { end_when: effectiveEndDate }), + }; + let endMs: number; if (hasTaskRunning) { @@ -147,17 +164,6 @@ export const transformGanttData = ({ endMs = dayjs(endDate).valueOf(); } - // Include scheduled/queued/start/end times in tooltip data whenever the timestamps exist. - // start_when/end_when are carried on every segment of a try so the tooltip reports the - // task's actual start and end on the scheduled and queued bars too, not just the - // execution bar's own bounds. - const tryWhenForTooltip = { - ...(scheduledMs === undefined ? {} : { scheduled_when: scheduledDttm }), - ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }), - ...(startDate === null ? {} : { start_when: startDate }), - ...(startDate === null && !hasTaskRunning ? {} : { end_when: dayjs(endMs).toISOString() }), - }; - if (scheduledMs !== undefined) { const scheduledEndMs = queuedMs ?? startMs ?? (hasTaskRunning || tryRow.state === "scheduled" ? Date.now() : endMs);