This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 479cd43d6a New gantt tab (#31806)
479cd43d6a is described below

commit 479cd43d6acb440c3b1948244594e8ca91214bc3
Author: Brent Bovenzi <[email protected]>
AuthorDate: Fri Jul 28 08:15:18 2023 +0800

    New gantt tab (#31806)
    
    * init new gantt chart
    
    set axes
    
    support task groups
    
    synced scrolling
    
    clean up var names and height/scroll logic
    
    * fix task group queued date
    
    * fix last task appearance
    
    * Reset out-of-sync grid/gantt scroll onSelect
    
    * fix www tests
    
    * Active gantt scroll bar, fix tabs and address pr feedback
    
    * Move checkScrollPosition to a timer
    
    * clean up gantt tooltip
---
 airflow/www/package.json                           |   2 +-
 airflow/www/static/js/dag/Main.tsx                 |   6 +
 .../static/js/dag/details/gantt/GanttTooltip.tsx   |  79 ++++++++++
 airflow/www/static/js/dag/details/gantt/Row.tsx    | 139 +++++++++++++++++
 airflow/www/static/js/dag/details/gantt/index.tsx  | 173 +++++++++++++++++++++
 airflow/www/static/js/dag/details/index.tsx        |  53 ++++++-
 airflow/www/static/js/dag/grid/index.tsx           |  37 ++++-
 airflow/www/static/js/types/index.ts               |   1 +
 airflow/www/utils.py                               |   5 +
 airflow/www/views.py                               |  22 +++
 airflow/www/yarn.lock                              |   8 +-
 tests/www/views/test_views_grid.py                 |  10 ++
 12 files changed, 522 insertions(+), 13 deletions(-)

diff --git a/airflow/www/package.json b/airflow/www/package.json
index aec3e3509f..8651ca59ba 100644
--- a/airflow/www/package.json
+++ b/airflow/www/package.json
@@ -129,7 +129,7 @@
     "nvd3": "^1.8.6",
     "react": "^18.0.0",
     "react-dom": "^18.0.0",
-    "react-icons": "^4.3.1",
+    "react-icons": "^4.9.0",
     "react-json-view": "^1.21.3",
     "react-markdown": "^8.0.4",
     "react-query": "^3.39.1",
diff --git a/airflow/www/static/js/dag/Main.tsx 
b/airflow/www/static/js/dag/Main.tsx
index 9a484046ce..99620a731b 100644
--- a/airflow/www/static/js/dag/Main.tsx
+++ b/airflow/www/static/js/dag/Main.tsx
@@ -68,6 +68,8 @@ const Main = () => {
   const [isGridCollapsed, setIsGridCollapsed] = useState(false);
   const resizeRef = useRef<HTMLDivElement>(null);
   const gridRef = useRef<HTMLDivElement>(null);
+  const gridScrollRef = useRef<HTMLDivElement>(null);
+  const ganttScrollRef = useRef<HTMLDivElement>(null);
   const isPanelOpen = localStorage.getItem(detailsPanelKey) !== "true";
   const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isPanelOpen });
   const [hoveredTaskState, setHoveredTaskState] = useState<
@@ -207,6 +209,8 @@ const Main = () => {
                 onToggleGroups={onToggleGroups}
                 isGridCollapsed={isGridCollapsed}
                 setIsGridCollapsed={onToggleGridCollapse}
+                gridScrollRef={gridScrollRef}
+                ganttScrollRef={ganttScrollRef}
               />
             </Box>
             {isOpen && (
@@ -229,6 +233,8 @@ const Main = () => {
                     openGroupIds={openGroupIds}
                     onToggleGroups={onToggleGroups}
                     hoveredTaskState={hoveredTaskState}
+                    gridScrollRef={gridScrollRef}
+                    ganttScrollRef={ganttScrollRef}
                   />
                 </Box>
               </>
diff --git a/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx 
b/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx
new file mode 100644
index 0000000000..2f7d617c8c
--- /dev/null
+++ b/airflow/www/static/js/dag/details/gantt/GanttTooltip.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 React from "react";
+import { Box, Text } from "@chakra-ui/react";
+import { getDuration, formatDuration } from "src/datetime_utils";
+import Time from "src/components/Time";
+import type { Task, TaskInstance } from "src/types";
+
+interface Props {
+  instance: TaskInstance;
+  task: Task;
+}
+
+const GanttTooltip = ({ task, instance }: Props) => {
+  const isGroup = !!task.children;
+  const isMappedOrGroupSummary = isGroup || task.isMapped;
+
+  // Calculate durations in ms
+  const taskDuration = getDuration(instance?.startDate, instance?.endDate);
+  const queuedDuration = instance?.queuedDttm
+    ? getDuration(instance.queuedDttm, instance?.startDate)
+    : 0;
+  return (
+    <Box>
+      <Text>
+        Task{isGroup ? " Group" : ""}: {task.label}
+      </Text>
+      <br />
+      {instance?.queuedDttm && (
+        <Text>
+          {isMappedOrGroupSummary && "Total "}Queued Duration:{" "}
+          {formatDuration(queuedDuration)}
+        </Text>
+      )}
+      <Text>
+        {isMappedOrGroupSummary && "Total "}Run Duration:{" "}
+        {formatDuration(taskDuration)}
+      </Text>
+      <br />
+      {instance?.queuedDttm && (
+        <Text>
+          {isMappedOrGroupSummary && "Earliest "}Queued At:{" "}
+          <Time dateTime={instance?.queuedDttm} />
+        </Text>
+      )}
+      {instance?.startDate && (
+        <Text>
+          {isMappedOrGroupSummary && "Earliest "}Start:{" "}
+          <Time dateTime={instance?.startDate} />
+        </Text>
+      )}
+      {instance?.endDate && (
+        <Text>
+          {isMappedOrGroupSummary && "Latest "}End:{" "}
+          <Time dateTime={instance?.endDate} />
+        </Text>
+      )}
+    </Box>
+  );
+};
+
+export default GanttTooltip;
diff --git a/airflow/www/static/js/dag/details/gantt/Row.tsx 
b/airflow/www/static/js/dag/details/gantt/Row.tsx
new file mode 100644
index 0000000000..96f079154e
--- /dev/null
+++ b/airflow/www/static/js/dag/details/gantt/Row.tsx
@@ -0,0 +1,139 @@
+/*!
+ * 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 React from "react";
+import { Box, Tooltip, Flex } from "@chakra-ui/react";
+import useSelection from "src/dag/useSelection";
+import { getDuration } from "src/datetime_utils";
+import { SimpleStatus } from "src/dag/StatusBox";
+import { useContainerRef } from "src/context/containerRef";
+import { hoverDelay } from "src/utils";
+import type { DagRun, Task } from "src/types";
+import GanttTooltip from "./GanttTooltip";
+
+interface Props {
+  ganttWidth?: number;
+  openGroupIds: string[];
+  dagRun: DagRun;
+  task: Task;
+}
+
+const Row = ({ ganttWidth = 500, openGroupIds, task, dagRun }: Props) => {
+  const {
+    selected: { runId, taskId },
+    onSelect,
+  } = useSelection();
+  const containerRef = useContainerRef();
+
+  const runDuration = getDuration(dagRun?.startDate, dagRun?.endDate);
+
+  const instance = task.instances.find((ti) => ti.runId === runId);
+  const isSelected = taskId === instance?.taskId;
+  const hasQueuedDttm = !!instance?.queuedDttm;
+  const isOpen = openGroupIds.includes(task.label || "");
+
+  // Calculate durations in ms
+  const taskDuration = getDuration(instance?.startDate, instance?.endDate);
+  const queuedDuration = hasQueuedDttm
+    ? getDuration(instance?.queuedDttm, instance?.startDate)
+    : 0;
+  const taskStartOffset = getDuration(
+    dagRun.startDate,
+    instance?.queuedDttm || instance?.startDate
+  );
+
+  // Percent of each duration vs the overall dag run
+  const taskDurationPercent = taskDuration / runDuration;
+  const taskStartOffsetPercent = taskStartOffset / runDuration;
+  const queuedDurationPercent = queuedDuration / runDuration;
+
+  // Calculate the pixel width of the queued and task bars and the position in 
the graph
+  // Min width should be 5px
+  let width = ganttWidth * taskDurationPercent;
+  if (width < 5) width = 5;
+  let queuedWidth = hasQueuedDttm ? ganttWidth * queuedDurationPercent : 0;
+  if (hasQueuedDttm && queuedWidth < 5) queuedWidth = 5;
+  const offsetMargin = taskStartOffsetPercent * ganttWidth;
+
+  return (
+    <div>
+      <Box
+        py="4px"
+        borderBottomWidth={1}
+        borderBottomColor={!!task.children && isOpen ? "gray.400" : "gray.200"}
+        bg={isSelected ? "blue.100" : "inherit"}
+      >
+        {instance ? (
+          <Tooltip
+            label={<GanttTooltip task={task} instance={instance} />}
+            hasArrow
+            portalProps={{ containerRef }}
+            placement="top"
+            openDelay={hoverDelay}
+          >
+            <Flex
+              width={`${width + queuedWidth}px`}
+              cursor="pointer"
+              pointerEvents="auto"
+              marginLeft={`${offsetMargin}px`}
+              onClick={() => {
+                onSelect({
+                  runId: instance.runId,
+                  taskId: instance.taskId,
+                });
+              }}
+            >
+              {instance.state !== "queued" && hasQueuedDttm && (
+                <SimpleStatus
+                  state="queued"
+                  width={`${queuedWidth}px`}
+                  borderRightRadius={0}
+                  // The normal queued color is too dark when next to the 
actual task's state
+                  opacity={0.6}
+                />
+              )}
+              <SimpleStatus
+                state={instance.state}
+                width={`${width}px`}
+                borderLeftRadius={
+                  instance.state !== "queued" && hasQueuedDttm ? 0 : undefined
+                }
+              />
+            </Flex>
+          </Tooltip>
+        ) : (
+          <Box height="10px" />
+        )}
+      </Box>
+      {isOpen &&
+        !!task.children &&
+        task.children.map((c) => (
+          <Row
+            ganttWidth={ganttWidth}
+            openGroupIds={openGroupIds}
+            dagRun={dagRun}
+            task={c}
+            key={`gantt-${c.id}`}
+          />
+        ))}
+    </div>
+  );
+};
+
+export default Row;
diff --git a/airflow/www/static/js/dag/details/gantt/index.tsx 
b/airflow/www/static/js/dag/details/gantt/index.tsx
new file mode 100644
index 0000000000..931c48b566
--- /dev/null
+++ b/airflow/www/static/js/dag/details/gantt/index.tsx
@@ -0,0 +1,173 @@
+/*!
+ * 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 React, { useCallback, useEffect, useRef, useState } from "react";
+import { Box, Divider, Text } from "@chakra-ui/react";
+
+import useSelection from "src/dag/useSelection";
+import { useGridData } from "src/api";
+import Time from "src/components/Time";
+import { getDuration } from "src/datetime_utils";
+
+import Row from "./Row";
+
+interface Props {
+  openGroupIds: string[];
+  gridScrollRef: React.RefObject<HTMLDivElement>;
+  ganttScrollRef: React.RefObject<HTMLDivElement>;
+}
+
+const Gantt = ({ openGroupIds, gridScrollRef, ganttScrollRef }: Props) => {
+  const ganttRef = useRef<HTMLDivElement>(null);
+  const [top, setTop] = useState(0);
+  const [width, setWidth] = useState(500);
+  const [height, setHeight] = useState("100%");
+  const {
+    selected: { runId, taskId },
+  } = useSelection();
+  const {
+    data: { dagRuns, groups },
+  } = useGridData();
+
+  const calculateGanttDimensions = useCallback(() => {
+    if (ganttRef?.current) {
+      const tbody = gridScrollRef.current?.getElementsByTagName("tbody")[0];
+      const tableTop =
+        (tbody?.getBoundingClientRect().top || 0) +
+        (gridScrollRef?.current?.scrollTop || 0);
+      const ganttRect = ganttRef?.current?.getBoundingClientRect();
+      const offsetTop = ganttRect?.top;
+      setTop(tableTop && offsetTop ? tableTop - offsetTop : 0);
+      if (ganttRect?.width) setWidth(ganttRect.width);
+      const gridHeight = gridScrollRef.current?.getBoundingClientRect().height;
+      if (gridHeight) setHeight(`${gridHeight - 155}px`);
+    }
+  }, [ganttRef, gridScrollRef]);
+
+  // Calculate top, height and width when changing selections
+  useEffect(() => {
+    calculateGanttDimensions();
+  }, [runId, taskId, openGroupIds, calculateGanttDimensions]);
+
+  const onGridScroll = (e: Event) => {
+    const { scrollTop } = e.currentTarget as HTMLDivElement;
+    if (scrollTop && ganttScrollRef?.current) {
+      ganttScrollRef.current.scrollTo(0, scrollTop);
+
+      // Double check scroll position after 100ms
+      setTimeout(() => {
+        const gridScrollTop = gridScrollRef.current?.scrollTop;
+        const ganttScrollTop = ganttScrollRef.current?.scrollTop;
+        if (ganttScrollTop !== gridScrollTop && ganttScrollRef.current) {
+          ganttScrollRef.current.scrollTo(0, gridScrollTop || 0);
+        }
+      }, 100);
+    }
+  };
+
+  // Sync grid and gantt scroll
+  useEffect(() => {
+    const grid = gridScrollRef.current;
+    grid?.addEventListener("scroll", onGridScroll);
+    return () => {
+      grid?.removeEventListener("scroll", onGridScroll);
+    };
+  });
+
+  // Calculate top, height and width on resize
+  useEffect(() => {
+    const ganttChart = ganttRef.current;
+    const ganttObserver = new ResizeObserver(calculateGanttDimensions);
+
+    if (ganttChart) {
+      ganttObserver.observe(ganttChart);
+      return () => {
+        ganttObserver.unobserve(ganttChart);
+      };
+    }
+    return () => {};
+  }, [ganttRef, calculateGanttDimensions]);
+
+  const dagRun = dagRuns.find((dr) => dr.runId === runId);
+
+  const startDate = dagRun?.startDate;
+
+  const numBars = Math.round(width / 100);
+  const runDuration = getDuration(dagRun?.startDate, dagRun?.endDate);
+  const intervals = runDuration / numBars;
+
+  return (
+    <Box ref={ganttRef} position="relative" height="100%" overflow="hidden">
+      <Box borderBottomWidth={1} pt={`${top}px`} pointerEvents="none">
+        {Array.from(Array(numBars)).map((_, i) => (
+          <Box
+            position="absolute"
+            left={`${(width / numBars) * i}px`}
+            // eslint-disable-next-line react/no-array-index-key
+            key={i}
+          >
+            <Text
+              color="gray.400"
+              fontSize="sm"
+              transform="rotate(-30deg) translateX(28px)"
+              mt={-6}
+              mb={1}
+              ml={-9}
+            >
+              <Time
+                dateTime={moment(startDate)
+                  .add(i * intervals, "milliseconds")
+                  .format()}
+                format="HH:mm:ss z"
+              />
+            </Text>
+            <Divider orientation="vertical" height={height} />
+          </Box>
+        ))}
+        <Box position="absolute" left={width - 2} key="end">
+          <Divider orientation="vertical" height={height} />
+        </Box>
+      </Box>
+      <Box
+        maxHeight={height}
+        height="100%"
+        overflowY="scroll"
+        ref={ganttScrollRef}
+        overscrollBehavior="contain"
+      >
+        <div>
+          {!!runId &&
+            !!dagRun &&
+            !!groups.children &&
+            groups.children.map((c) => (
+              <Row
+                ganttWidth={width}
+                openGroupIds={openGroupIds}
+                dagRun={dagRun}
+                task={c}
+                key={`gantt-${c.id}`}
+              />
+            ))}
+        </div>
+      </Box>
+    </Box>
+  );
+};
+
+export default Gantt;
diff --git a/airflow/www/static/js/dag/details/index.tsx 
b/airflow/www/static/js/dag/details/index.tsx
index a4caa7236a..e505fd406e 100644
--- a/airflow/www/static/js/dag/details/index.tsx
+++ b/airflow/www/static/js/dag/details/index.tsx
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import React, { useCallback } from "react";
+import React, { useCallback, useEffect } from "react";
 import {
   Flex,
   Divider,
@@ -33,7 +33,13 @@ import { useSearchParams } from "react-router-dom";
 import useSelection from "src/dag/useSelection";
 import { getTask, getMetaValue } from "src/utils";
 import { useGridData, useTaskInstance } from "src/api";
-import { MdDetails, MdAccountTree, MdReorder, MdCode } from "react-icons/md";
+import {
+  MdDetails,
+  MdAccountTree,
+  MdReorder,
+  MdCode,
+  MdOutlineViewTimeline,
+} from "react-icons/md";
 import { BiBracket } from "react-icons/bi";
 import URLSearchParamsWrapper from "src/utils/URLSearchParamWrapper";
 
@@ -42,6 +48,7 @@ import TaskInstanceContent from "./taskInstance";
 import DagRunContent from "./dagRun";
 import DagContent from "./Dag";
 import Graph from "./graph";
+import Gantt from "./gantt";
 import DagCode from "./dagCode";
 import MappedInstances from "./taskInstance/MappedInstances";
 import Logs from "./taskInstance/Logs";
@@ -58,16 +65,20 @@ interface Props {
   openGroupIds: string[];
   onToggleGroups: (groupIds: string[]) => void;
   hoveredTaskState?: string | null;
+  gridScrollRef: React.RefObject<HTMLDivElement>;
+  ganttScrollRef: React.RefObject<HTMLDivElement>;
 }
 
 const tabToIndex = (tab?: string) => {
   switch (tab) {
     case "graph":
       return 1;
+    case "gantt":
+      return 2;
     case "code":
     case "logs":
     case "mapped_tasks":
-      return 2;
+      return 3;
     case "details":
     default:
       return 0;
@@ -84,6 +95,8 @@ const indexToTab = (
     case 1:
       return "graph";
     case 2:
+      return "gantt";
+    case 3:
       if (!taskId) return "code";
       if (showMappedTasks) return "mapped_tasks";
       if (showLogs) return "logs";
@@ -96,7 +109,13 @@ const indexToTab = (
 
 const TAB_PARAM = "tab";
 
-const Details = ({ openGroupIds, onToggleGroups, hoveredTaskState }: Props) => 
{
+const Details = ({
+  openGroupIds,
+  onToggleGroups,
+  hoveredTaskState,
+  gridScrollRef,
+  ganttScrollRef,
+}: Props) => {
   const {
     selected: { runId, taskId, mapIndex },
     onSelect,
@@ -134,6 +153,15 @@ const Details = ({ openGroupIds, onToggleGroups, 
hoveredTaskState }: Props) => {
     [setSearchParams, searchParams, showLogs, showMappedTasks, taskId]
   );
 
+  useEffect(() => {
+    // We only have 3 tabs for when nothing or a task group are selected
+    const tabCount =
+      (runId && !taskId) || (runId && taskId && !isGroup) ? 4 : 3;
+    if (tabCount === 3 && tabIndex > 2) {
+      onChangeTab(1);
+    }
+  }, [taskId, runId, tabIndex, isGroup, onChangeTab]);
+
   const run = dagRuns.find((r) => r.runId === runId);
   const { data: mappedTaskInstance } = useTaskInstance({
     dagId,
@@ -212,6 +240,14 @@ const Details = ({ openGroupIds, onToggleGroups, 
hoveredTaskState }: Props) => {
               Graph
             </Text>
           </Tab>
+          {run && (
+            <Tab>
+              <MdOutlineViewTimeline size={16} />
+              <Text as="strong" ml={1}>
+                Gantt
+              </Text>
+            </Tab>
+          )}
           {showDagCode && (
             <Tab>
               <MdCode size={16} />
@@ -262,6 +298,15 @@ const Details = ({ openGroupIds, onToggleGroups, 
hoveredTaskState }: Props) => {
               hoveredTaskState={hoveredTaskState}
             />
           </TabPanel>
+          {run && (
+            <TabPanel p={0} height="100%">
+              <Gantt
+                openGroupIds={openGroupIds}
+                gridScrollRef={gridScrollRef}
+                ganttScrollRef={ganttScrollRef}
+              />
+            </TabPanel>
+          )}
           {showDagCode && (
             <TabPanel height="100%">
               <DagCode />
diff --git a/airflow/www/static/js/dag/grid/index.tsx 
b/airflow/www/static/js/dag/grid/index.tsx
index b598a7fb58..a67305994a 100644
--- a/airflow/www/static/js/dag/grid/index.tsx
+++ b/airflow/www/static/js/dag/grid/index.tsx
@@ -39,6 +39,8 @@ interface Props {
   onToggleGroups: (groupIds: string[]) => void;
   isGridCollapsed?: boolean;
   setIsGridCollapsed?: (collapsed: boolean) => void;
+  gridScrollRef?: React.RefObject<HTMLDivElement>;
+  ganttScrollRef?: React.RefObject<HTMLDivElement>;
 }
 
 const Grid = ({
@@ -49,8 +51,9 @@ const Grid = ({
   onToggleGroups,
   isGridCollapsed,
   setIsGridCollapsed,
+  gridScrollRef,
+  ganttScrollRef,
 }: Props) => {
-  const scrollRef = useRef<HTMLDivElement>(null);
   const tableRef = useRef<HTMLTableSectionElement>(null);
   const offsetTop = useOffsetTop(tableRef);
   const { selected } = useSelection();
@@ -68,9 +71,34 @@ const Grid = ({
       return true;
     });
 
+  const onGanttScroll = (e: Event) => {
+    const { scrollTop } = e.currentTarget as HTMLDivElement;
+    if (scrollTop && gridScrollRef?.current) {
+      gridScrollRef.current.scrollTo(0, scrollTop);
+
+      // Double check the scroll position after 100ms
+      setTimeout(() => {
+        const gridScrollTop = gridScrollRef?.current?.scrollTop;
+        const ganttScrollTop = ganttScrollRef?.current?.scrollTop;
+        if (ganttScrollTop !== gridScrollTop && gridScrollRef?.current) {
+          gridScrollRef.current.scrollTo(0, ganttScrollTop || 0);
+        }
+      }, 100);
+    }
+  };
+
+  // Sync grid and gantt scroll
+  useEffect(() => {
+    const gantt = ganttScrollRef?.current;
+    gantt?.addEventListener("scroll", onGanttScroll);
+    return () => {
+      gantt?.removeEventListener("scroll", onGanttScroll);
+    };
+  });
+
   useEffect(() => {
     const scrollOnResize = new ResizeObserver(() => {
-      const runsContainer = scrollRef.current;
+      const runsContainer = gridScrollRef?.current;
       // Set scroll to top right if it is scrollable
       if (
         tableRef?.current &&
@@ -90,7 +118,7 @@ const Grid = ({
       };
     }
     return () => {};
-  }, [tableRef, isGridCollapsed]);
+  }, [tableRef, isGridCollapsed, gridScrollRef]);
 
   return (
     <Box height="100%" position="relative">
@@ -134,11 +162,12 @@ const Grid = ({
       )}
       <Box
         maxHeight={`calc(100% - ${offsetTop}px)`}
-        ref={scrollRef}
+        ref={gridScrollRef}
         overflow="auto"
         position="relative"
         pr={4}
         mt={8}
+        overscrollBehavior="contain"
       >
         <Table pr="10px">
           <Thead>
diff --git a/airflow/www/static/js/types/index.ts 
b/airflow/www/static/js/types/index.ts
index 09a7ca4f9b..4fbb1b0ccc 100644
--- a/airflow/www/static/js/types/index.ts
+++ b/airflow/www/static/js/types/index.ts
@@ -71,6 +71,7 @@ interface TaskInstance {
   mappedStates?: {
     [key: string]: number;
   };
+  queuedDttm?: string | null;
   mapIndex?: number;
   tryNumber?: number;
   triggererJob?: Job;
diff --git a/airflow/www/utils.py b/airflow/www/utils.py
index 8d5ee60d0d..6d5f4d7420 100644
--- a/airflow/www/utils.py
+++ b/airflow/www/utils.py
@@ -125,6 +125,10 @@ def get_mapped_summary(parent_instance, task_instances):
             group_state = state
             break
 
+    group_queued_dttm = datetime_to_string(
+        min((ti.queued_dttm for ti in task_instances if ti.queued_dttm), 
default=None)
+    )
+
     group_start_date = datetime_to_string(
         min((ti.start_date for ti in task_instances if ti.start_date), 
default=None)
     )
@@ -136,6 +140,7 @@ def get_mapped_summary(parent_instance, task_instances):
         "task_id": parent_instance.task_id,
         "run_id": parent_instance.run_id,
         "state": group_state,
+        "queued_dttm": group_queued_dttm,
         "start_date": group_start_date,
         "end_date": group_end_date,
         "mapped_states": mapped_states,
diff --git a/airflow/www/views.py b/airflow/www/views.py
index ae8811c05c..636d7c0f87 100644
--- a/airflow/www/views.py
+++ b/airflow/www/views.py
@@ -304,6 +304,7 @@ def dag_to_grid(dag: DagModel, dag_runs: Sequence[DagRun], 
session: Session):
             TaskInstance._try_number,
             func.min(TaskInstanceNote.content).label("note"),
             func.count(func.coalesce(TaskInstance.state, 
sqla.literal("no_status"))).label("state_count"),
+            func.min(TaskInstance.queued_dttm).label("queued_dttm"),
             func.min(TaskInstance.start_date).label("start_date"),
             func.max(TaskInstance.end_date).label("end_date"),
         )
@@ -334,6 +335,7 @@ def dag_to_grid(dag: DagModel, dag_runs: Sequence[DagRun], 
session: Session):
                     "task_id": task_instance.task_id,
                     "run_id": task_instance.run_id,
                     "state": task_instance.state,
+                    "queued_dttm": task_instance.queued_dttm,
                     "start_date": task_instance.start_date,
                     "end_date": task_instance.end_date,
                     "try_number": 
wwwutils.get_try_count(task_instance._try_number, task_instance.state),
@@ -363,15 +365,26 @@ def dag_to_grid(dag: DagModel, dag_runs: 
Sequence[DagRun], session: Session):
                         record = {
                             "task_id": ti_summary.task_id,
                             "run_id": run_id,
+                            "queued_dttm": ti_summary.queued_dttm,
                             "start_date": ti_summary.start_date,
                             "end_date": ti_summary.end_date,
                             "mapped_states": {ti_summary.state: 
ti_summary.state_count},
                             "state": None,  # We change this before yielding
                         }
                         continue
+                    record["queued_dttm"] = min(
+                        filter(None, [record["queued_dttm"], 
ti_summary.queued_dttm]), default=None
+                    )
                     record["start_date"] = min(
                         filter(None, [record["start_date"], 
ti_summary.start_date]), default=None
                     )
+                    # Sometimes the start date of a group might be before the 
queued date of the group
+                    if (
+                        record["queued_dttm"]
+                        and record["start_date"]
+                        and record["queued_dttm"] > record["start_date"]
+                    ):
+                        record["queued_dttm"] = None
                     record["end_date"] = max(
                         filter(None, [record["end_date"], 
ti_summary.end_date]), default=None
                     )
@@ -421,18 +434,24 @@ def dag_to_grid(dag: DagModel, dag_runs: 
Sequence[DagRun], session: Session):
                 if item
             ]
 
+            children_queued_dttms = (item["queued_dttm"] for item in 
child_instances)
             children_start_dates = (item["start_date"] for item in 
child_instances)
             children_end_dates = (item["end_date"] for item in child_instances)
             children_states = {item["state"] for item in child_instances}
 
             group_state = next((state for state in wwwutils.priority if state 
in children_states), None)
+            group_queued_dttm = min(filter(None, children_queued_dttms), 
default=None)
             group_start_date = min(filter(None, children_start_dates), 
default=None)
             group_end_date = max(filter(None, children_end_dates), 
default=None)
+            # Sometimes the start date of a group might be before the queued 
date of the group
+            if group_queued_dttm and group_start_date and group_queued_dttm > 
group_start_date:
+                group_queued_dttm = None
 
             return {
                 "task_id": task_group.group_id,
                 "run_id": dag_run.run_id,
                 "state": group_state,
+                "queued_dttm": group_queued_dttm,
                 "start_date": group_start_date,
                 "end_date": group_end_date,
             }
@@ -462,6 +481,7 @@ def dag_to_grid(dag: DagModel, dag_runs: Sequence[DagRun], 
session: Session):
                     if item and item["run_id"] == run_id
                 ]
 
+                children_queued_dttms = (item["queued_dttm"] for item in 
child_instances)
                 children_start_dates = (item["start_date"] for item in 
child_instances)
                 children_end_dates = (item["end_date"] for item in 
child_instances)
                 children_states = {item["state"] for item in child_instances}
@@ -477,6 +497,7 @@ def dag_to_grid(dag: DagModel, dag_runs: Sequence[DagRun], 
session: Session):
                     mapped_states[value] += 1
 
                 group_state = next((state for state in wwwutils.priority if 
state in children_states), None)
+                group_queued_dttm = min(filter(None, children_queued_dttms), 
default=None)
                 group_start_date = min(filter(None, children_start_dates), 
default=None)
                 group_end_date = max(filter(None, children_end_dates), 
default=None)
 
@@ -484,6 +505,7 @@ def dag_to_grid(dag: DagModel, dag_runs: Sequence[DagRun], 
session: Session):
                     "task_id": task_group.group_id,
                     "run_id": run_id,
                     "state": group_state,
+                    "queued_dttm": group_queued_dttm,
                     "start_date": group_start_date,
                     "end_date": group_end_date,
                     "mapped_states": mapped_states,
diff --git a/airflow/www/yarn.lock b/airflow/www/yarn.lock
index a8b4499904..26f41798fa 100644
--- a/airflow/www/yarn.lock
+++ b/airflow/www/yarn.lock
@@ -9805,10 +9805,10 @@ react-focus-lock@^2.9.1:
     use-callback-ref "^1.3.0"
     use-sidecar "^1.1.2"
 
-react-icons@^4.3.1:
-  version "4.3.1"
-  resolved 
"https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca";
-  integrity 
sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==
+react-icons@^4.9.0:
+  version "4.9.0"
+  resolved 
"https://registry.yarnpkg.com/react-icons/-/react-icons-4.9.0.tgz#ba44f436a053393adb1bdcafbc5c158b7b70d2a3";
+  integrity 
sha512-ijUnFr//ycebOqujtqtV9PFS7JjhWg0QU6ykURVHuL4cbofvRCf3f6GMn9+fBktEFQOIVZnuAYLZdiyadRQRFg==
 
 react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.1:
   version "16.13.1"
diff --git a/tests/www/views/test_views_grid.py 
b/tests/www/views/test_views_grid.py
index 171b48211f..57e7e56865 100644
--- a/tests/www/views/test_views_grid.py
+++ b/tests/www/views/test_views_grid.py
@@ -238,6 +238,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                     "instances": [
                         {
                             "run_id": "run_1",
+                            "queued_dttm": None,
                             "start_date": None,
                             "end_date": None,
                             "note": None,
@@ -247,6 +248,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                         },
                         {
                             "run_id": "run_2",
+                            "queued_dttm": None,
                             "start_date": None,
                             "end_date": None,
                             "note": None,
@@ -270,6 +272,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                                 {
                                     "run_id": "run_1",
                                     "mapped_states": {"success": 3},
+                                    "queued_dttm": None,
                                     "start_date": None,
                                     "end_date": None,
                                     "state": "success",
@@ -278,6 +281,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                                 {
                                     "run_id": "run_2",
                                     "mapped_states": {"no_status": 3},
+                                    "queued_dttm": None,
                                     "start_date": None,
                                     "end_date": None,
                                     "state": None,
@@ -297,12 +301,14 @@ def test_one_run(admin_client, dag_with_runs: 
list[DagRun], session):
                             "end_date": None,
                             "run_id": "run_1",
                             "mapped_states": {"success": 3},
+                            "queued_dttm": None,
                             "start_date": None,
                             "state": "success",
                             "task_id": "mapped_task_group",
                         },
                         {
                             "run_id": "run_2",
+                            "queued_dttm": None,
                             "start_date": None,
                             "end_date": None,
                             "state": None,
@@ -323,6 +329,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                                 {
                                     "run_id": "run_1",
                                     "mapped_states": {"success": 4},
+                                    "queued_dttm": None,
                                     "start_date": None,
                                     "end_date": None,
                                     "state": "success",
@@ -331,6 +338,7 @@ def test_one_run(admin_client, dag_with_runs: list[DagRun], 
session):
                                 {
                                     "run_id": "run_2",
                                     "mapped_states": {"no_status": 2, 
"running": 1, "success": 1},
+                                    "queued_dttm": None,
                                     "start_date": "2021-07-01T01:00:00+00:00",
                                     "end_date": "2021-07-01T01:02:03+00:00",
                                     "state": "running",
@@ -348,12 +356,14 @@ def test_one_run(admin_client, dag_with_runs: 
list[DagRun], session):
                         {
                             "end_date": None,
                             "run_id": "run_1",
+                            "queued_dttm": None,
                             "start_date": None,
                             "state": "success",
                             "task_id": "group",
                         },
                         {
                             "run_id": "run_2",
+                            "queued_dttm": None,
                             "start_date": "2021-07-01T01:00:00+00:00",
                             "end_date": "2021-07-01T01:02:03+00:00",
                             "state": "running",


Reply via email to