From 424d8662f8f3de681b68453e57c1bb3aa6213894 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Wed, 6 Aug 2025 02:40:30 +0800 Subject: [PATCH 01/12] Add Gantt chart --- airflow-core/src/airflow/ui/package.json | 1 + airflow-core/src/airflow/ui/pnpm-lock.yaml | 15 ++ .../ui/public/i18n/locales/en/dag.json | 1 + .../ui/public/i18n/locales/zh-TW/dag.json | 1 + .../ui/src/layouts/Details/DetailsLayout.tsx | 21 +- .../ui/src/layouts/Details/Gantt/Gantt.tsx | 246 ++++++++++++++++++ .../ui/src/layouts/Details/Gantt/index.ts | 20 ++ .../ui/src/layouts/Details/Grid/Grid.tsx | 2 +- .../ui/src/layouts/Details/PanelButtons.tsx | 77 +++--- .../src/airflow/ui/src/utils/datetimeUtils.ts | 14 + 10 files changed, 366 insertions(+), 32 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx create mode 100644 airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts diff --git a/airflow-core/src/airflow/ui/package.json b/airflow-core/src/airflow/ui/package.json index 30b4eaedd4fb6..927364de54ab6 100644 --- a/airflow-core/src/airflow/ui/package.json +++ b/airflow-core/src/airflow/ui/package.json @@ -32,6 +32,7 @@ "axios": "^1.8.4", "chakra-react-select": "6.1.0", "chart.js": "^4.4.9", + "chartjs-adapter-dayjs-4": "^1.0.4", "chartjs-plugin-annotation": "^3.1.0", "dayjs": "^1.11.13", "debounce-promise": "^3.1.2", diff --git a/airflow-core/src/airflow/ui/pnpm-lock.yaml b/airflow-core/src/airflow/ui/pnpm-lock.yaml index 0f84ca9ef7902..d78abd26fdcc0 100644 --- a/airflow-core/src/airflow/ui/pnpm-lock.yaml +++ b/airflow-core/src/airflow/ui/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: chart.js: specifier: ^4.4.9 version: 4.4.9 + chartjs-adapter-dayjs-4: + specifier: ^1.0.4 + version: 1.0.4(chart.js@4.4.9)(dayjs@1.11.13) chartjs-plugin-annotation: specifier: ^3.1.0 version: 3.1.0(chart.js@4.4.9) @@ -1975,6 +1978,13 @@ packages: resolution: {integrity: sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==} engines: {pnpm: '>=8'} + chartjs-adapter-dayjs-4@1.0.4: + resolution: {integrity: sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==} + engines: {node: '>=10'} + peerDependencies: + chart.js: '>=4.0.1' + dayjs: ^1.9.7 + chartjs-plugin-annotation@3.1.0: resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} peerDependencies: @@ -7010,6 +7020,11 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 + chartjs-adapter-dayjs-4@1.0.4(chart.js@4.4.9)(dayjs@1.11.13): + dependencies: + chart.js: 4.4.9 + dayjs: 1.11.13 + chartjs-plugin-annotation@3.1.0(chart.js@4.4.9): dependencies: chart.js: 4.4.9 diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json index a3086158bc405..fc8463c169f78 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json @@ -65,6 +65,7 @@ "panel": { "buttons": { "options": "Options", + "showGantt": "Show Gantt", "showGraph": "Show Graph", "showGrid": "Show Grid" }, diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json index 622b088679bb0..8e9f4fb6e6942 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json @@ -65,6 +65,7 @@ "panel": { "buttons": { "options": "選項", + "showGantt": "顯示甘特圖", "showGraph": "顯示圖表", "showGrid": "顯示網格" }, diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index cc9ca8a975d44..21a2f66171e62 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -39,6 +39,7 @@ import { Tooltip } from "src/components/ui/Tooltip"; import { OpenGroupsProvider } from "src/context/openGroups"; import { DagBreadcrumb } from "./DagBreadcrumb"; +import { Gantt } from "./Gantt/Gantt"; import { Graph } from "./Graph"; import { Grid } from "./Grid"; import { NavTabs } from "./NavTabs"; @@ -58,6 +59,8 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const panelGroupRef = useRef(null); const [dagView, setDagView] = useLocalStorage<"graph" | "grid">(`dag_view-${dagId}`, defaultDagView); const [limit, setLimit] = useLocalStorage(`dag_runs_limit-${dagId}`, 10); + + const [showGantt, setShowGantt] = useLocalStorage(`show_gantt-${dagId}`, true); const { fitView, getZoom } = useReactFlow(); const { data: warningData } = useDagWarningServiceListDagWarnings({ dagId }); const { onClose, onOpen, open } = useDisclosure(); @@ -110,7 +113,12 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { key={`${dagView}-${direction}`} ref={panelGroupRef} > - + { panelGroupRef={panelGroupRef} setDagView={setDagView} setLimit={setLimit} + setShowGantt={setShowGantt} + showGantt={showGantt} /> - {dagView === "graph" ? : } + {dagView === "graph" ? ( + + ) : ( + + + {showGantt ? : undefined} + + )} {!isRightPanelCollapsed && ( diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx new file mode 100644 index 0000000000000..098220528e8f7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -0,0 +1,246 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Box } from "@chakra-ui/react"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Title, + Tooltip, + Legend, + TimeScale, +} from "chart.js"; +import "chart.js/auto"; +import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; +import annotationPlugin from "chartjs-plugin-annotation"; +import { useMemo, useRef } from "react"; +import { Bar } from "react-chartjs-2"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; + +import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries"; +import { useOpenGroups } from "src/context/openGroups"; +import { useTimezone } from "src/context/timezone"; +import { flattenNodes } from "src/layouts/Details/Grid/utils"; +import { useGridRuns } from "src/queries/useGridRuns"; +import { useGridStructure } from "src/queries/useGridStructure"; +import { useGridTiSummaries } from "src/queries/useGridTISummaries"; +import { system } from "src/theme"; +import { getDuration } from "src/utils"; +import { formatDate } from "src/utils/datetimeUtils"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + LineElement, + Filler, + Title, + Tooltip, + Legend, + annotationPlugin, + TimeScale, +); + +type Props = { + readonly limit: number; +}; + +const CHART_PADDING = 36; +const CHART_ROW_HEIGHT = 20; + +export const Gantt = ({ limit }: Props) => { + const { dagId = "", groupId: selectedGroupId, runId, taskId: selectedTaskId } = useParams(); + const { openGroupIds } = useOpenGroups(); + const { t: translate } = useTranslation("common"); + const { selectedTimezone } = useTimezone(); + const ref = useRef(); + + const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); + const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); + const selectedRun = gridRuns?.find((run) => run.run_id === runId); + + // Get grid summaries for groups (which have min/max times) + const { data: gridTiSummaries, isLoading: summariesLoading } = useGridTiSummaries({ + dagId, + runId: runId ?? "", + state: selectedRun?.state, + }); + + // Get individual task instances for tasks (which have start/end times) + const { data: taskInstancesData, isLoading: tiLoading } = useTaskInstanceServiceGetTaskInstances({ + dagId, + dagRunId: runId ?? "~", + }); + + const { flatNodes } = useMemo(() => flattenNodes(dagStructure, openGroupIds), [dagStructure, openGroupIds]); + + const isLoading = runsLoading || structureLoading || summariesLoading || tiLoading; + + const gridSummaries = gridTiSummaries?.task_instances ?? []; + const taskInstances = taskInstancesData?.task_instances ?? []; + + if (isLoading || runId === undefined) { + return undefined; + } + + const data = flatNodes + .map((node) => { + const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id); + + if (node.isGroup && gridSummary) { + // Group node - use min/max times from grid summary + return { + isGroup: true, + state: gridSummary.state, + x: [ + formatDate(gridSummary.min_start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(gridSummary.max_end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + ], + y: gridSummary.task_id, + }; + } else if (!node.isGroup) { + // Individual task - use individual task instance data + const taskInstance = taskInstances.find((ti) => ti.task_id === node.id); + + if (taskInstance) { + return { + isGroup: false, + state: taskInstance.state, + x: [ + formatDate(taskInstance.start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(taskInstance.end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + ], + y: taskInstance.task_id, + }; + } + } + + return undefined; + }) + .filter((item) => item !== undefined); + + const fixedHeight = data.length * CHART_ROW_HEIGHT + CHART_PADDING; + const selectedId = selectedTaskId ?? selectedGroupId; + + return ( + + + system.tokens.categoryMap.get("colors")?.get(`${dataItem.state}.600`)?.value as string, + ), + data, + maxBarThickness: CHART_ROW_HEIGHT, + }, + ], + labels: [], + }} + datasetIdKey="id" + options={{ + animation: { + duration: 100, + }, + indexAxis: "y", + maintainAspectRatio: false, + plugins: { + annotation: { + annotations: + selectedId === undefined + ? [] + : [ + { + backgroundColor: system.tokens.categoryMap.get("colors")?.get(`blue.300`) + ?.value as string, + borderWidth: 0, + drawTime: "beforeDatasetsDraw", + type: "box", + xMax: "max", + xMin: "min", + yMax: data.findIndex((dataItem) => dataItem.y === selectedId) + 0.5, + yMin: data.findIndex((dataItem) => dataItem.y === selectedId) - 0.5, + }, + ], + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + afterBody(tooltipItems) { + const taskInstance = data.find((dataItem) => dataItem.y === tooltipItems[0]?.label); + const startDate = formatDate(taskInstance?.x[0], selectedTimezone); + const endDate = formatDate(taskInstance?.x[1], selectedTimezone); + + return [ + `${translate("startDate")}: ${startDate}`, + `${translate("endDate")}: ${endDate}`, + `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, + ]; + }, + label(tooltipItem) { + const { label } = tooltipItem; + const taskInstance = data.find((dataItem) => dataItem.y === label); + + return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; + }, + }, + }, + }, + resizeDelay: 100, + responsive: true, + scales: { + x: { + max: formatDate(selectedRun?.end_date, selectedTimezone), + min: formatDate(selectedRun?.start_date, selectedTimezone), + position: "top", + stacked: true, + ticks: { + align: "start", + callback: (value) => formatDate(value, selectedTimezone, "HH:mm:ss"), + maxRotation: 10, + maxTicksLimit: 8, + minRotation: 10, + }, + type: "time", + }, + y: { + grid: { + display: true, + }, + stacked: true, + ticks: { + display: false, + }, + }, + }, + }} + ref={ref} + /> + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts new file mode 100644 index 0000000000000..24f6dabe4cfe7 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/index.ts @@ -0,0 +1,20 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from "./Gantt"; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx index f8a618015c38c..55f5f73a5717c 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx @@ -138,7 +138,7 @@ export const Grid = ({ limit }: Props) => { onMouseDown={() => setGridFocus(true)} outline="none" position="relative" - pt={50} + pt={20} ref={gridRef} tabIndex={0} width="100%" diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx index 52dbd974dab31..1cf05cab7fb5b 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx @@ -25,6 +25,7 @@ import { Popover, Portal, Select, + VStack, } from "@chakra-ui/react"; import { useReactFlow } from "@xyflow/react"; import { useTranslation } from "react-i18next"; @@ -36,6 +37,7 @@ import { useLocalStorage } from "usehooks-ts"; import { DagVersionSelect } from "src/components/DagVersionSelect"; import { directionOptions, type Direction } from "src/components/Graph/useGraphLayout"; import { Button } from "src/components/ui"; +import { Checkbox } from "src/components/ui/Checkbox"; import { DagRunSelect } from "./DagRunSelect"; import { ToggleGroups } from "./ToggleGroups"; @@ -46,6 +48,8 @@ type Props = { readonly panelGroupRef: React.RefObject<{ setLayout?: (layout: Array) => void } & HTMLDivElement>; readonly setDagView: (x: "graph" | "grid") => void; readonly setLimit: React.Dispatch>; + readonly setShowGantt: React.Dispatch>; + readonly showGantt: boolean; }; const getOptions = (translate: (key: string) => string) => @@ -72,7 +76,15 @@ const deps = ["all", "immediate", "tasks"]; type Dependency = (typeof deps)[number]; -export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLimit }: Props) => { +export const PanelButtons = ({ + dagView, + limit, + panelGroupRef, + setDagView, + setLimit, + setShowGantt, + showGantt, +}: Props) => { const { t: translate } = useTranslation(["components", "dag"]); const { dagId = "" } = useParams(); const { fitView } = useReactFlow(); @@ -165,7 +177,7 @@ export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLim - + {dagView === "graph" ? ( <> @@ -227,33 +239,40 @@ export const PanelButtons = ({ dagView, limit, panelGroupRef, setDagView, setLim ) : ( - - {translate("dag:panel.dagRuns.label")} - - - - - - - - - - - {displayRunOptions.items.map((option) => ( - - {option.label} - - ))} - - - + <> + + {translate("dag:panel.dagRuns.label")} + + + + + + + + + + + {displayRunOptions.items.map((option) => ( + + {option.label} + + ))} + + + + + setShowGantt(!showGantt)} size="sm"> + {translate("dag:panel.buttons.showGantt")} + + + )} diff --git a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts index 4691859d5d37a..bcc1f65731635 100644 --- a/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts +++ b/airflow-core/src/airflow/ui/src/utils/datetimeUtils.ts @@ -18,8 +18,10 @@ */ import dayjs from "dayjs"; import dayjsDuration from "dayjs/plugin/duration"; +import tz from "dayjs/plugin/timezone"; dayjs.extend(dayjsDuration); +dayjs.extend(tz); export const renderDuration = (durationSeconds: number | null | undefined): string => { if ( @@ -45,3 +47,15 @@ export const getDuration = (startDate?: string | null, endDate?: string | null) return renderDuration(seconds); }; + +export const formatDate = ( + date: number | string | null | undefined, + timezone: string, + format: string = "YYYY-MM-DD HH:mm:ss", +) => { + if (date === null || date === undefined || !dayjs(date).isValid()) { + return dayjs().tz(timezone).format(format); + } + + return dayjs(date).tz(timezone).format(format); +}; From d0728382826def151fc309d7e8ae3e62508a95eb Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Thu, 14 Aug 2025 17:56:58 +0800 Subject: [PATCH 02/12] Fix grid color in dark mode --- .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 098220528e8f7..aba4c444910a2 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Box } from "@chakra-ui/react"; +import { Box, useToken } from "@chakra-ui/react"; import { Chart as ChartJS, CategoryScale, @@ -39,6 +39,7 @@ import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries"; +import { useColorMode } from "src/context/colorMode"; import { useOpenGroups } from "src/context/openGroups"; import { useTimezone } from "src/context/timezone"; import { flattenNodes } from "src/layouts/Details/Grid/utils"; @@ -75,8 +76,13 @@ export const Gantt = ({ limit }: Props) => { const { openGroupIds } = useOpenGroups(); const { t: translate } = useTranslation("common"); const { selectedTimezone } = useTimezone(); + const { colorMode } = useColorMode(); const ref = useRef(); + // Get theme-aware colors for grid lines + const [lightGridColor, darkGridColor] = useToken("colors", ["gray.200", "gray.800"]); + const gridColor = colorMode === "light" ? lightGridColor : darkGridColor; + const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); const selectedRun = gridRuns?.find((run) => run.run_id === runId); @@ -215,6 +221,10 @@ export const Gantt = ({ limit }: Props) => { responsive: true, scales: { x: { + grid: { + color: gridColor, + display: true, + }, max: formatDate(selectedRun?.end_date, selectedTimezone), min: formatDate(selectedRun?.start_date, selectedTimezone), position: "top", @@ -230,6 +240,7 @@ export const Gantt = ({ limit }: Props) => { }, y: { grid: { + color: gridColor, display: true, }, stacked: true, From c94e63f4538e2f07960b150a5d21daadc47582c7 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Thu, 14 Aug 2025 18:09:20 +0800 Subject: [PATCH 03/12] Sync selected color with Grid --- .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index aba4c444910a2..24290cd58bd2d 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -79,9 +79,15 @@ export const Gantt = ({ limit }: Props) => { const { colorMode } = useColorMode(); const ref = useRef(); - // Get theme-aware colors for grid lines - const [lightGridColor, darkGridColor] = useToken("colors", ["gray.200", "gray.800"]); + // Get theme-aware colors for grid lines and selection + const [lightGridColor, darkGridColor, lightSelectedColor, darkSelectedColor] = useToken("colors", [ + "gray.200", + "gray.800", + "blue.200", + "blue.800", + ]); const gridColor = colorMode === "light" ? lightGridColor : darkGridColor; + const selectedItemColor = colorMode === "light" ? lightSelectedColor : darkSelectedColor; const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); @@ -180,8 +186,7 @@ export const Gantt = ({ limit }: Props) => { ? [] : [ { - backgroundColor: system.tokens.categoryMap.get("colors")?.get(`blue.300`) - ?.value as string, + backgroundColor: selectedItemColor, borderWidth: 0, drawTime: "beforeDatasetsDraw", type: "box", From 114d4c51d2d082b6035923c416d5d50599c724df Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Thu, 14 Aug 2025 18:47:16 +0800 Subject: [PATCH 04/12] Enable showGantt button for run or TI selected --- .../ui/src/layouts/Details/PanelButtons.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx index 1cf05cab7fb5b..b8bc6332b1584 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx @@ -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 @@ -86,8 +88,9 @@ export const PanelButtons = ({ showGantt, }: Props) => { const { t: translate } = useTranslation(["components", "dag"]); - const { dagId = "" } = useParams(); + const { dagId = "", runId, taskId } = useParams(); const { fitView } = useReactFlow(); + const shouldShowToggleButtons = Boolean(runId ?? taskId); const [dependencies, setDependencies, removeDependencies] = useLocalStorage( `dependencies-${dagId}`, "tasks", @@ -267,11 +270,13 @@ export const PanelButtons = ({ - - setShowGantt(!showGantt)} size="sm"> - {translate("dag:panel.buttons.showGantt")} - - + {shouldShowToggleButtons ? ( + + setShowGantt(!showGantt)} size="sm"> + {translate("dag:panel.buttons.showGantt")} + + + ) : undefined} )} From 0ecbb701d39f5d8ec9b413042a687c11f3d340d4 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Fri, 15 Aug 2025 00:37:10 +0800 Subject: [PATCH 05/12] Set minimum width for TI --- .../src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 24290cd58bd2d..a5e6722026fbe 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -70,6 +70,7 @@ type Props = { const CHART_PADDING = 36; const CHART_ROW_HEIGHT = 20; +const MIN_BAR_WIDTH = 10; export const Gantt = ({ limit }: Props) => { const { dagId = "", groupId: selectedGroupId, runId, taskId: selectedTaskId } = useParams(); @@ -79,7 +80,6 @@ export const Gantt = ({ limit }: Props) => { const { colorMode } = useColorMode(); const ref = useRef(); - // Get theme-aware colors for grid lines and selection const [lightGridColor, darkGridColor, lightSelectedColor, darkSelectedColor] = useToken("colors", [ "gray.200", "gray.800", @@ -168,6 +168,7 @@ export const Gantt = ({ limit }: Props) => { ), data, maxBarThickness: CHART_ROW_HEIGHT, + minBarLength: MIN_BAR_WIDTH, }, ], labels: [], From 201625ba2b2ff74dee59ba4148aca443b9e38ba1 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Fri, 15 Aug 2025 01:33:54 +0800 Subject: [PATCH 06/12] Fix spacing --- .../src/airflow/ui/src/layouts/Details/DetailsLayout.tsx | 2 +- airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index 21a2f66171e62..22b1b015b7454 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -132,7 +132,7 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { {dagView === "graph" ? ( ) : ( - + {showGantt ? : undefined} diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index a5e6722026fbe..400bd41d09d06 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -157,7 +157,7 @@ export const Gantt = ({ limit }: Props) => { const selectedId = selectedTaskId ?? selectedGroupId; return ( - + Date: Fri, 15 Aug 2025 02:53:28 +0800 Subject: [PATCH 07/12] Fix single task layout --- .../src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 400bd41d09d06..9e8cb7c4e05b3 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -238,9 +238,9 @@ export const Gantt = ({ limit }: Props) => { ticks: { align: "start", callback: (value) => formatDate(value, selectedTimezone, "HH:mm:ss"), - maxRotation: 10, + maxRotation: 8, maxTicksLimit: 8, - minRotation: 10, + minRotation: 8, }, type: "time", }, @@ -257,6 +257,9 @@ export const Gantt = ({ limit }: Props) => { }, }} ref={ref} + style={{ + paddingTop: data.length === 1 ? 14.5 : 1.5, + }} /> ); From 289ccad8c24d04e7ea3c4f62dfc25b5e61dc6d79 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Fri, 15 Aug 2025 03:21:23 +0800 Subject: [PATCH 08/12] Update auto-refresh for TI and useMemo --- .../ui/src/layouts/Details/Gantt/Gantt.tsx | 291 ++++++++++-------- 1 file changed, 158 insertions(+), 133 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 9e8cb7c4e05b3..21fb21528babd 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -29,6 +29,7 @@ import { Tooltip, Legend, TimeScale, + type TooltipItem, } from "chart.js"; import "chart.js/auto"; import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; @@ -47,7 +48,7 @@ import { useGridRuns } from "src/queries/useGridRuns"; import { useGridStructure } from "src/queries/useGridStructure"; import { useGridTiSummaries } from "src/queries/useGridTISummaries"; import { system } from "src/theme"; -import { getDuration } from "src/utils"; +import { getDuration, isStatePending, useAutoRefresh } from "src/utils"; import { formatDate } from "src/utils/datetimeUtils"; ChartJS.register( @@ -92,6 +93,7 @@ export const Gantt = ({ limit }: Props) => { const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ limit }); const { data: dagStructure, isLoading: structureLoading } = useGridStructure({ limit }); const selectedRun = gridRuns?.find((run) => run.run_id === runId); + const refetchInterval = useAutoRefresh({ dagId }); // Get grid summaries for groups (which have min/max times) const { data: gridTiSummaries, isLoading: summariesLoading } = useGridTiSummaries({ @@ -101,161 +103,184 @@ export const Gantt = ({ limit }: Props) => { }); // Get individual task instances for tasks (which have start/end times) - const { data: taskInstancesData, isLoading: tiLoading } = useTaskInstanceServiceGetTaskInstances({ - dagId, - dagRunId: runId ?? "~", - }); + const { data: taskInstancesData, isLoading: tiLoading } = useTaskInstanceServiceGetTaskInstances( + { + dagId, + dagRunId: runId ?? "~", + }, + undefined, + { + enabled: Boolean(dagId), + refetchInterval: (query) => + query.state.data?.task_instances.some((ti) => isStatePending(ti.state)) ? refetchInterval : false, + }, + ); const { flatNodes } = useMemo(() => flattenNodes(dagStructure, openGroupIds), [dagStructure, openGroupIds]); const isLoading = runsLoading || structureLoading || summariesLoading || tiLoading; - const gridSummaries = gridTiSummaries?.task_instances ?? []; - const taskInstances = taskInstancesData?.task_instances ?? []; + const data = useMemo(() => { + if (isLoading || runId === undefined) { + return []; + } - if (isLoading || runId === undefined) { - return undefined; - } - - const data = flatNodes - .map((node) => { - const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id); + const gridSummaries = gridTiSummaries?.task_instances ?? []; + const taskInstances = taskInstancesData?.task_instances ?? []; - if (node.isGroup && gridSummary) { - // Group node - use min/max times from grid summary - return { - isGroup: true, - state: gridSummary.state, - x: [ - formatDate(gridSummary.min_start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), - formatDate(gridSummary.max_end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), - ], - y: gridSummary.task_id, - }; - } else if (!node.isGroup) { - // Individual task - use individual task instance data - const taskInstance = taskInstances.find((ti) => ti.task_id === node.id); + return flatNodes + .map((node) => { + const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id); - if (taskInstance) { + if (node.isGroup && gridSummary) { + // Group node - use min/max times from grid summary return { - isGroup: false, - state: taskInstance.state, + isGroup: true, + state: gridSummary.state, x: [ - formatDate(taskInstance.start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), - formatDate(taskInstance.end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(gridSummary.min_start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(gridSummary.max_end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), ], - y: taskInstance.task_id, + y: gridSummary.task_id, }; + } else if (!node.isGroup) { + // Individual task - use individual task instance data + const taskInstance = taskInstances.find((ti) => ti.task_id === node.id); + + if (taskInstance) { + return { + isGroup: false, + state: taskInstance.state, + x: [ + formatDate(taskInstance.start_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + formatDate(taskInstance.end_date, selectedTimezone, "YYYY-MM-DD HH:mm:ss.SSS"), + ], + y: taskInstance.task_id, + }; + } } - } - return undefined; - }) - .filter((item) => item !== undefined); + return undefined; + }) + .filter((item) => item !== undefined); + }, [flatNodes, gridTiSummaries, taskInstancesData, selectedTimezone, isLoading, runId]); + + const chartData = useMemo( + () => ({ + datasets: [ + { + backgroundColor: data.map( + (dataItem) => + system.tokens.categoryMap.get("colors")?.get(`${dataItem.state}.600`)?.value as string, + ), + data, + maxBarThickness: CHART_ROW_HEIGHT, + minBarLength: MIN_BAR_WIDTH, + }, + ], + labels: [], + }), + [data], + ); const fixedHeight = data.length * CHART_ROW_HEIGHT + CHART_PADDING; const selectedId = selectedTaskId ?? selectedGroupId; - return ( - - - system.tokens.categoryMap.get("colors")?.get(`${dataItem.state}.600`)?.value as string, - ), - data, - maxBarThickness: CHART_ROW_HEIGHT, - minBarLength: MIN_BAR_WIDTH, - }, - ], - labels: [], - }} - datasetIdKey="id" - options={{ - animation: { - duration: 100, - }, - indexAxis: "y", - maintainAspectRatio: false, - plugins: { - annotation: { - annotations: - selectedId === undefined - ? [] - : [ - { - backgroundColor: selectedItemColor, - borderWidth: 0, - drawTime: "beforeDatasetsDraw", - type: "box", - xMax: "max", - xMin: "min", - yMax: data.findIndex((dataItem) => dataItem.y === selectedId) + 0.5, - yMin: data.findIndex((dataItem) => dataItem.y === selectedId) - 0.5, - }, - ], - }, - legend: { - display: false, - }, - tooltip: { - callbacks: { - afterBody(tooltipItems) { - const taskInstance = data.find((dataItem) => dataItem.y === tooltipItems[0]?.label); - const startDate = formatDate(taskInstance?.x[0], selectedTimezone); - const endDate = formatDate(taskInstance?.x[1], selectedTimezone); + const chartOptions = useMemo( + () => ({ + animation: { + duration: 100, + }, + indexAxis: "y" as const, + maintainAspectRatio: false, + plugins: { + annotation: { + annotations: + selectedId === undefined + ? [] + : [ + { + backgroundColor: selectedItemColor, + borderWidth: 0, + drawTime: "beforeDatasetsDraw" as const, + type: "box" as const, + xMax: "max" as const, + xMin: "min" as const, + yMax: data.findIndex((dataItem) => dataItem.y === selectedId) + 0.5, + yMin: data.findIndex((dataItem) => dataItem.y === selectedId) - 0.5, + }, + ], + }, + legend: { + display: false, + }, + tooltip: { + callbacks: { + afterBody(tooltipItems: Array>) { + const taskInstance = data.find((dataItem) => dataItem.y === tooltipItems[0]?.label); + const startDate = formatDate(taskInstance?.x[0], selectedTimezone); + const endDate = formatDate(taskInstance?.x[1], selectedTimezone); - return [ - `${translate("startDate")}: ${startDate}`, - `${translate("endDate")}: ${endDate}`, - `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, - ]; - }, - label(tooltipItem) { - const { label } = tooltipItem; - const taskInstance = data.find((dataItem) => dataItem.y === label); + return [ + `${translate("startDate")}: ${startDate}`, + `${translate("endDate")}: ${endDate}`, + `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, + ]; + }, + label(tooltipItem: TooltipItem<"bar">) { + const { label } = tooltipItem; + const taskInstance = data.find((dataItem) => dataItem.y === label); - return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; - }, - }, + return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; }, }, - resizeDelay: 100, - responsive: true, - scales: { - x: { - grid: { - color: gridColor, - display: true, - }, - max: formatDate(selectedRun?.end_date, selectedTimezone), - min: formatDate(selectedRun?.start_date, selectedTimezone), - position: "top", - stacked: true, - ticks: { - align: "start", - callback: (value) => formatDate(value, selectedTimezone, "HH:mm:ss"), - maxRotation: 8, - maxTicksLimit: 8, - minRotation: 8, - }, - type: "time", - }, - y: { - grid: { - color: gridColor, - display: true, - }, - stacked: true, - ticks: { - display: false, - }, - }, + }, + }, + resizeDelay: 100, + responsive: true, + scales: { + x: { + grid: { + color: gridColor, + display: true, }, - }} + max: formatDate(selectedRun?.end_date, selectedTimezone), + min: formatDate(selectedRun?.start_date, selectedTimezone), + position: "top" as const, + stacked: true, + ticks: { + align: "start" as const, + callback: (value: number | string) => formatDate(value, selectedTimezone, "HH:mm:ss"), + maxRotation: 8, + maxTicksLimit: 8, + minRotation: 8, + }, + type: "time" as const, + }, + y: { + grid: { + color: gridColor, + display: true, + }, + stacked: true, + ticks: { + display: false, + }, + }, + }, + }), + [data, selectedId, selectedItemColor, gridColor, selectedRun, selectedTimezone, translate], + ); + + if (isLoading || runId === undefined) { + return undefined; + } + + return ( + + Date: Fri, 15 Aug 2025 13:39:46 +0800 Subject: [PATCH 09/12] Fix button visibility condition --- .../src/airflow/ui/src/layouts/Details/PanelButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx index b8bc6332b1584..2684be54a6435 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx @@ -88,9 +88,9 @@ export const PanelButtons = ({ showGantt, }: Props) => { const { t: translate } = useTranslation(["components", "dag"]); - const { dagId = "", runId, taskId } = useParams(); + const { dagId = "", runId } = useParams(); const { fitView } = useReactFlow(); - const shouldShowToggleButtons = Boolean(runId ?? taskId); + const shouldShowToggleButtons = Boolean(runId); const [dependencies, setDependencies, removeDependencies] = useLocalStorage( `dependencies-${dagId}`, "tasks", From 5fe4ce33f2c389e4517ae393b5d4a5da6c68ac38 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Fri, 15 Aug 2025 15:21:37 +0800 Subject: [PATCH 10/12] Update fixedHeight --- .../src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 21fb21528babd..8bdb4689b9c11 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -183,7 +183,7 @@ export const Gantt = ({ limit }: Props) => { [data], ); - const fixedHeight = data.length * CHART_ROW_HEIGHT + CHART_PADDING; + const fixedHeight = flatNodes.length * CHART_ROW_HEIGHT + CHART_PADDING; const selectedId = selectedTaskId ?? selectedGroupId; const chartOptions = useMemo( @@ -272,7 +272,7 @@ export const Gantt = ({ limit }: Props) => { [data, selectedId, selectedItemColor, gridColor, selectedRun, selectedTimezone, translate], ); - if (isLoading || runId === undefined) { + if (runId === undefined) { return undefined; } @@ -283,7 +283,7 @@ export const Gantt = ({ limit }: Props) => { options={chartOptions} ref={ref} style={{ - paddingTop: data.length === 1 ? 14.5 : 1.5, + paddingTop: flatNodes.length === 1 ? 15 : 1.5, }} /> From 092ec462a6c36e05546feacfe1951bf33dec3e42 Mon Sep 17 00:00:00 2001 From: GUAN MING Date: Tue, 19 Aug 2025 22:56:22 +0800 Subject: [PATCH 11/12] Update DetailsLayout and Grid layout --- .../airflow/ui/src/layouts/Details/DetailsLayout.tsx | 6 +++--- .../src/airflow/ui/src/layouts/Details/Grid/Grid.tsx | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index 22b1b015b7454..e536061cded1a 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -53,7 +53,7 @@ type Props = { export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const { t: translate } = useTranslation(); - const { dagId = "" } = useParams(); + const { dagId = "", runId } = useParams(); const { data: dag } = useDagServiceGetDag({ dagId }); const [defaultDagView] = useLocalStorage<"graph" | "grid">("default_dag_view", "grid"); const panelGroupRef = useRef(null); @@ -116,7 +116,7 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { @@ -133,7 +133,7 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { ) : ( - + {showGantt ? : undefined} )} diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx index 55f5f73a5717c..a5b1fb4429882 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx @@ -55,9 +55,10 @@ const getArrowsForMode = (navigationMode: string) => { type Props = { readonly limit: number; + readonly showGantt?: boolean; }; -export const Grid = ({ limit }: Props) => { +export const Grid = ({ limit, showGantt }: Props) => { const { t: translate } = useTranslation("dag"); const gridRef = useRef(null); const { isGridFocused, setIsGridFocused } = useGridStore(); @@ -141,7 +142,7 @@ export const Grid = ({ limit }: Props) => { pt={20} ref={gridRef} tabIndex={0} - width="100%" + width={showGantt ? undefined : "100%"} > {translate("navigation.navigation", { arrow: getArrowsForMode(mode) })} @@ -157,7 +158,12 @@ export const Grid = ({ limit }: Props) => { - + {Boolean(gridRuns?.length) && ( <> From c7443640619f36c35bbec255ae1f6fae3ed6b593 Mon Sep 17 00:00:00 2001 From: "Guan Ming(Wesley) Chiu" <105915352+guan404ming@users.noreply.github.com> Date: Wed, 20 Aug 2025 08:33:45 +0800 Subject: [PATCH 12/12] Update airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx Co-authored-by: Brent Bovenzi --- airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 8bdb4689b9c11..8e1568aa0e23f 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -277,7 +277,7 @@ export const Gantt = ({ limit }: Props) => { } return ( - +