Skip to content

Commit 0b2efb9

Browse files
authored
Fix Gantt view "Error invalid date" on running DagRun (#64752)
* Fix Gantt view "Error invalid date" on running DagRun (#64599) The Gantt chart scale calculation used new Date() to parse date strings, which fails for non-UTC timezone abbreviations, returning NaN and crashing Chart.js's time scale. Changes: - Replace new Date().getTime() with dayjs().valueOf() for reliable date parsing in the x-axis scale min/max calculations. - Add unit tests for Gantt chart scale calculations and data transformation covering completed tasks, running tasks with null end dates, groups with null dates, and ISO date string validity. * Address review feedback: fix static checks and consistency - Fix object property ordering in test data (alphabetical) - Import vi as type-only from vitest - Fix duration field position in selectedRun objects - Make dayjs null handling consistent between max and min scale calculations (remove unnecessary ?? undefined) * Fix ESLint no-empty-function error in Gantt utils test * Fix TypeScript type errors in Gantt utils tests Update test fixtures to match current generated types: - Add has_missed_deadline to GridRunsResponse objects - Remove dag_id and map_index from GanttTaskInstance objects - Add depth to GridTask objects - Add task_display_name to LightGridTaskInstanceSummary objects * Fix translate type to use TFunction in Gantt utils tests
1 parent a06896f commit 0b2efb9

2 files changed

Lines changed: 265 additions & 4 deletions

File tree

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/*!
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import type { ChartEvent, ActiveElement } from "chart.js";
20+
import dayjs from "dayjs";
21+
import type { TFunction } from "i18next";
22+
import { describe, it, expect } from "vitest";
23+
24+
import type { GanttDataItem } from "./utils";
25+
import { createChartOptions, transformGanttData } from "./utils";
26+
27+
// eslint-disable-next-line no-empty-function, @typescript-eslint/no-empty-function
28+
const noop = () => {};
29+
30+
const defaultChartParams = {
31+
gridColor: "#ccc",
32+
handleBarClick: noop as (event: ChartEvent, elements: Array<ActiveElement>) => void,
33+
handleBarHover: noop as (event: ChartEvent, elements: Array<ActiveElement>) => void,
34+
hoveredId: undefined,
35+
hoveredItemColor: "#eee",
36+
labels: ["task_1", "task_2"],
37+
selectedId: undefined,
38+
selectedItemColor: "#ddd",
39+
selectedTimezone: "UTC",
40+
translate: ((key: string) => key) as unknown as TFunction,
41+
};
42+
43+
describe("createChartOptions", () => {
44+
describe("x-axis scale min/max with ISO date strings", () => {
45+
it("should compute valid min/max for completed tasks with ISO dates", () => {
46+
const data: Array<GanttDataItem> = [
47+
{
48+
state: "success",
49+
taskId: "task_1",
50+
x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
51+
y: "task_1",
52+
},
53+
{
54+
state: "success",
55+
taskId: "task_2",
56+
x: ["2024-03-14T10:03:00.000Z", "2024-03-14T10:10:00.000Z"],
57+
y: "task_2",
58+
},
59+
];
60+
61+
const options = createChartOptions({
62+
...defaultChartParams,
63+
data,
64+
selectedRun: {
65+
dag_id: "test_dag",
66+
duration: 600,
67+
end_date: "2024-03-14T10:10:00+00:00",
68+
has_missed_deadline: false,
69+
queued_at: "2024-03-14T09:59:00+00:00",
70+
run_after: "2024-03-14T10:00:00+00:00",
71+
run_id: "run_1",
72+
run_type: "manual",
73+
start_date: "2024-03-14T10:00:00+00:00",
74+
state: "success",
75+
},
76+
});
77+
78+
const xScale = options.scales.x;
79+
80+
expect(xScale.min).toBeTypeOf("number");
81+
expect(xScale.max).toBeTypeOf("number");
82+
expect(Number.isNaN(xScale.min)).toBe(false);
83+
expect(Number.isNaN(xScale.max)).toBe(false);
84+
// max should be slightly beyond the latest end date (5% padding)
85+
expect(xScale.max).toBeGreaterThan(new Date("2024-03-14T10:10:00.000Z").getTime());
86+
});
87+
88+
it("should compute valid min/max for running tasks", () => {
89+
const now = dayjs().toISOString();
90+
const data: Array<GanttDataItem> = [
91+
{
92+
state: "success",
93+
taskId: "task_1",
94+
x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
95+
y: "task_1",
96+
},
97+
{
98+
state: "running",
99+
taskId: "task_2",
100+
x: ["2024-03-14T10:05:00.000Z", now],
101+
y: "task_2",
102+
},
103+
];
104+
105+
const options = createChartOptions({
106+
...defaultChartParams,
107+
data,
108+
selectedRun: {
109+
dag_id: "test_dag",
110+
duration: 0,
111+
// eslint-disable-next-line unicorn/no-null
112+
end_date: null,
113+
has_missed_deadline: false,
114+
queued_at: "2024-03-14T09:59:00+00:00",
115+
run_after: "2024-03-14T10:00:00+00:00",
116+
run_id: "run_1",
117+
run_type: "manual",
118+
start_date: "2024-03-14T10:00:00+00:00",
119+
state: "running",
120+
},
121+
});
122+
123+
const xScale = options.scales.x;
124+
125+
expect(xScale.min).toBeTypeOf("number");
126+
expect(xScale.max).toBeTypeOf("number");
127+
expect(Number.isNaN(xScale.min)).toBe(false);
128+
expect(Number.isNaN(xScale.max)).toBe(false);
129+
});
130+
131+
it("should handle empty data with running DagRun (fallback to formatted dates)", () => {
132+
const options = createChartOptions({
133+
...defaultChartParams,
134+
data: [],
135+
labels: [],
136+
selectedRun: {
137+
dag_id: "test_dag",
138+
duration: 0,
139+
// eslint-disable-next-line unicorn/no-null
140+
end_date: null,
141+
has_missed_deadline: false,
142+
queued_at: "2024-03-14T09:59:00+00:00",
143+
run_after: "2024-03-14T10:00:00+00:00",
144+
run_id: "run_1",
145+
run_type: "manual",
146+
start_date: "2024-03-14T10:00:00+00:00",
147+
state: "running",
148+
},
149+
});
150+
151+
const xScale = options.scales.x;
152+
153+
// With empty data, min/max are formatted date strings (fallback branch)
154+
expect(xScale.min).toBeTypeOf("string");
155+
expect(xScale.max).toBeTypeOf("string");
156+
});
157+
});
158+
});
159+
160+
describe("transformGanttData", () => {
161+
it("should skip tasks with null start_date", () => {
162+
const result = transformGanttData({
163+
allTries: [
164+
{
165+
// eslint-disable-next-line unicorn/no-null
166+
end_date: null,
167+
is_mapped: false,
168+
// eslint-disable-next-line unicorn/no-null
169+
start_date: null,
170+
// eslint-disable-next-line unicorn/no-null
171+
state: null,
172+
task_display_name: "task_1",
173+
task_id: "task_1",
174+
try_number: 1,
175+
},
176+
],
177+
flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" }],
178+
gridSummaries: [],
179+
});
180+
181+
expect(result).toHaveLength(0);
182+
});
183+
184+
it("should include running tasks with valid start_date and use current time as end", () => {
185+
const before = dayjs();
186+
const result = transformGanttData({
187+
allTries: [
188+
{
189+
// eslint-disable-next-line unicorn/no-null
190+
end_date: null,
191+
is_mapped: false,
192+
start_date: "2024-03-14T10:00:00+00:00",
193+
state: "running",
194+
task_display_name: "task_1",
195+
task_id: "task_1",
196+
try_number: 1,
197+
},
198+
],
199+
flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" }],
200+
gridSummaries: [],
201+
});
202+
203+
expect(result).toHaveLength(1);
204+
expect(result[0]?.state).toBe("running");
205+
// End time should be approximately now (ISO string)
206+
const endTime = dayjs(result[0]?.x[1]);
207+
208+
expect(endTime.valueOf()).toBeGreaterThanOrEqual(before.valueOf());
209+
});
210+
211+
it("should skip groups with null min_start_date or max_end_date", () => {
212+
const result = transformGanttData({
213+
allTries: [],
214+
flatNodes: [{ depth: 0, id: "group_1", is_mapped: false, isGroup: true, label: "group_1" }],
215+
gridSummaries: [
216+
{
217+
// eslint-disable-next-line unicorn/no-null
218+
child_states: null,
219+
// eslint-disable-next-line unicorn/no-null
220+
max_end_date: null,
221+
// eslint-disable-next-line unicorn/no-null
222+
min_start_date: null,
223+
// eslint-disable-next-line unicorn/no-null
224+
state: null,
225+
task_display_name: "group_1",
226+
task_id: "group_1",
227+
},
228+
],
229+
});
230+
231+
expect(result).toHaveLength(0);
232+
});
233+
234+
it("should produce ISO date strings parseable by dayjs", () => {
235+
const result = transformGanttData({
236+
allTries: [
237+
{
238+
end_date: "2024-03-14T10:05:00+00:00",
239+
is_mapped: false,
240+
start_date: "2024-03-14T10:00:00+00:00",
241+
state: "success",
242+
task_display_name: "task_1",
243+
task_id: "task_1",
244+
try_number: 1,
245+
},
246+
],
247+
flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" }],
248+
gridSummaries: [],
249+
});
250+
251+
expect(result).toHaveLength(1);
252+
// x values should be valid ISO strings that dayjs can parse without NaN
253+
const start = dayjs(result[0]?.x[0]);
254+
const end = dayjs(result[0]?.x[1]);
255+
256+
expect(start.isValid()).toBe(true);
257+
expect(end.isValid()).toBe(true);
258+
expect(Number.isNaN(start.valueOf())).toBe(false);
259+
expect(Number.isNaN(end.valueOf())).toBe(false);
260+
});
261+
});

airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,8 @@ export const createChartOptions = ({
347347
max:
348348
data.length > 0
349349
? (() => {
350-
const maxTime = Math.max(...data.map((item) => new Date(item.x[1] ?? "").getTime()));
351-
const minTime = Math.min(...data.map((item) => new Date(item.x[0] ?? "").getTime()));
350+
const maxTime = Math.max(...data.map((item) => dayjs(item.x[1]).valueOf()));
351+
const minTime = Math.min(...data.map((item) => dayjs(item.x[0]).valueOf()));
352352
const totalDuration = maxTime - minTime;
353353

354354
// add 5% to the max time to avoid the last tick being cut off
@@ -358,8 +358,8 @@ export const createChartOptions = ({
358358
min:
359359
data.length > 0
360360
? (() => {
361-
const maxTime = Math.max(...data.map((item) => new Date(item.x[1] ?? "").getTime()));
362-
const minTime = Math.min(...data.map((item) => new Date(item.x[0] ?? "").getTime()));
361+
const maxTime = Math.max(...data.map((item) => dayjs(item.x[1]).valueOf()));
362+
const minTime = Math.min(...data.map((item) => dayjs(item.x[0]).valueOf()));
363363
const totalDuration = maxTime - minTime;
364364

365365
// subtract 2% from min time so background color shows before data

0 commit comments

Comments
 (0)