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..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"; @@ -237,6 +237,70 @@ 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 + // (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("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 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(); + } + }); + 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..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 @@ -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,11 +137,21 @@ 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. + // 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;