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 06350716d6d Add grid data to graph (#44854)
06350716d6d is described below

commit 06350716d6d44c268e88d1efc33ef59ea92b018a
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Jan 8 09:35:46 2025 -0500

    Add grid data to graph (#44854)
    
    * Add grid data to graph view
    
    * Reformat
    
    * Fix rebasing errors
    
    * Address PR feedback
    
    * Fix prop names, remove modal flicker, allow user to unselect task
    
    ---------
    
    Co-authored-by: Bugra Ozturk <[email protected]>
---
 airflow/ui/src/components/TaskInstanceTooltip.tsx  |  71 ++++++++------
 airflow/ui/src/components/ui/Status.tsx            |   4 +-
 airflow/ui/src/layouts/Details/DagRunSelect.tsx    |  92 ++++++++++++++++++
 airflow/ui/src/layouts/Details/DagVizModal.tsx     |  16 ++--
 airflow/ui/src/layouts/Details/Graph/Edge.tsx      |   4 +-
 airflow/ui/src/layouts/Details/Graph/Graph.tsx     |  96 +++++++++++++++++--
 airflow/ui/src/layouts/Details/Graph/JoinNode.tsx  |   7 +-
 airflow/ui/src/layouts/Details/Graph/TaskName.tsx  |  51 +++++++---
 airflow/ui/src/layouts/Details/Graph/TaskNode.tsx  | 106 +++++++++++++--------
 .../ui/src/layouts/Details/Graph/reactflowUtils.ts |  11 ++-
 .../ui/src/layouts/Details/Graph/useGraphLayout.ts |  13 +--
 11 files changed, 362 insertions(+), 109 deletions(-)

diff --git a/airflow/ui/src/components/TaskInstanceTooltip.tsx 
b/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 87d6cd503ff..bc44a23856f 100644
--- a/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -18,41 +18,52 @@
  */
 import { Box, Text } from "@chakra-ui/react";
 
-import type { TaskInstanceHistoryResponse, TaskInstanceResponse } from 
"openapi/requests/types.gen";
+import type {
+  TaskInstanceHistoryResponse,
+  TaskInstanceResponse,
+  GridTaskInstanceSummary,
+} from "openapi/requests/types.gen";
 import Time from "src/components/Time";
 import { Tooltip, type TooltipProps } from "src/components/ui";
 
 type Props = {
-  readonly taskInstance: TaskInstanceHistoryResponse | TaskInstanceResponse;
+  readonly taskInstance?: GridTaskInstanceSummary | 
TaskInstanceHistoryResponse | TaskInstanceResponse;
 } & Omit<TooltipProps, "content">;
 
-const TaskInstanceTooltip = ({ children, taskInstance }: Props) => (
-  <Tooltip
-    content={
-      <Box>
-        <Text>Run ID: {taskInstance.dag_run_id}</Text>
-        <Text>
-          Start Date: <Time datetime={taskInstance.start_date} />
-        </Text>
-        <Text>
-          End Date: <Time datetime={taskInstance.end_date} />
-        </Text>
-        {taskInstance.try_number > 1 && <Text>Try Number: 
{taskInstance.try_number}</Text>}
-        <Text>Duration: {taskInstance.duration?.toFixed(2) ?? 0}s</Text>
-        <Text>State: {taskInstance.state}</Text>
-      </Box>
-    }
-    key={taskInstance.dag_run_id}
-    positioning={{
-      offset: {
-        crossAxis: 5,
-        mainAxis: 5,
-      },
-      placement: "bottom-start",
-    }}
-  >
-    {children}
-  </Tooltip>
-);
+const TaskInstanceTooltip = ({ children, positioning, taskInstance, ...rest }: 
Props) =>
+  taskInstance === undefined ? (
+    children
+  ) : (
+    <Tooltip
+      {...rest}
+      content={
+        <Box>
+          {"dag_run_id" in taskInstance ? <Text>Run ID: 
{taskInstance.dag_run_id}</Text> : undefined}
+          <Text>
+            Start Date: <Time datetime={taskInstance.start_date} />
+          </Text>
+          <Text>
+            End Date: <Time datetime={taskInstance.end_date} />
+          </Text>
+          {taskInstance.try_number > 1 && <Text>Try Number: 
{taskInstance.try_number}</Text>}
+          {"duration" in taskInstance ? (
+            <Text>Duration: {taskInstance.duration?.toFixed(2) ?? 0}s</Text>
+          ) : undefined}
+        </Box>
+      }
+      key={taskInstance.task_id}
+      portalled
+      positioning={{
+        offset: {
+          crossAxis: 5,
+          mainAxis: 5,
+        },
+        placement: "bottom-start",
+        ...positioning,
+      }}
+    >
+      {children}
+    </Tooltip>
+  );
 
 export default TaskInstanceTooltip;
diff --git a/airflow/ui/src/components/ui/Status.tsx 
b/airflow/ui/src/components/ui/Status.tsx
index 192038a815c..91ef7e15309 100644
--- a/airflow/ui/src/components/ui/Status.tsx
+++ b/airflow/ui/src/components/ui/Status.tsx
@@ -22,10 +22,10 @@ import * as React from "react";
 import type { DagRunState, TaskInstanceState } from 
"openapi/requests/types.gen";
 import { stateColor } from "src/utils/stateColor";
 
-type StatusValue = DagRunState | TaskInstanceState;
+type StatusValue = DagRunState | TaskInstanceState | null;
 
 export type StatusProps = {
-  state: StatusValue | null;
+  state: StatusValue;
 } & ChakraStatus.RootProps;
 
 export const Status = React.forwardRef<HTMLDivElement, StatusProps>(({ 
children, state, ...rest }, ref) => {
diff --git a/airflow/ui/src/layouts/Details/DagRunSelect.tsx 
b/airflow/ui/src/layouts/Details/DagRunSelect.tsx
new file mode 100644
index 00000000000..9f2d4ce29c6
--- /dev/null
+++ b/airflow/ui/src/layouts/Details/DagRunSelect.tsx
@@ -0,0 +1,92 @@
+/*!
+ * 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 { createListCollection, type SelectValueChangeDetails } from 
"@chakra-ui/react";
+import { forwardRef, type RefObject } from "react";
+import { useNavigate, useParams, useSearchParams } from "react-router-dom";
+
+import { useGridServiceGridData } from "openapi/queries";
+import type { GridDAGRunwithTIs } from "openapi/requests/types.gen";
+import { Select, Status } from "src/components/ui";
+
+type DagRunSelected = {
+  run: GridDAGRunwithTIs;
+  value: string;
+};
+
+export const DagRunSelect = forwardRef<HTMLDivElement>((_, ref) => {
+  const { dagId = "", runId, taskId } = useParams();
+  const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
+
+  const { data, isLoading } = useGridServiceGridData(
+    {
+      dagId,
+      limit: 14,
+      offset: 0,
+      orderBy: "-start_date",
+    },
+    undefined,
+  );
+
+  const runOptions = createListCollection<DagRunSelected>({
+    items: (data?.dag_runs ?? []).map((dr: GridDAGRunwithTIs) => ({
+      run: dr,
+      value: dr.dag_run_id,
+    })),
+  });
+
+  const selectDagRun = ({ items }: SelectValueChangeDetails<DagRunSelected>) =>
+    navigate({
+      pathname: `/dags/${dagId}/runs/${items[0]?.run.dag_run_id}/${taskId === 
undefined ? "" : `tasks/${taskId}`}`,
+      search: searchParams.toString(),
+    });
+
+  return (
+    <Select.Root
+      collection={runOptions}
+      colorPalette="blue"
+      data-testid="dag-run-select"
+      disabled={isLoading}
+      maxWidth="400px"
+      onValueChange={selectDagRun}
+      value={runId === undefined ? [] : [runId]}
+      variant="subtle"
+    >
+      <Select.Trigger>
+        <Select.ValueText placeholder="Run">
+          {(items: Array<DagRunSelected>) => (
+            <Status
+              // eslint-disable-next-line unicorn/no-null
+              state={items[0]?.run.state ?? null}
+            >
+              {items[0]?.value}
+            </Status>
+          )}
+        </Select.ValueText>
+      </Select.Trigger>
+      <Select.Content portalRef={ref as RefObject<HTMLElement>} 
zIndex="popover">
+        {runOptions.items.map((option) => (
+          <Select.Item item={option} key={option.value}>
+            <Status state={option.run.state}>{option.value}</Status>
+          </Select.Item>
+        ))}
+      </Select.Content>
+    </Select.Root>
+  );
+});
diff --git a/airflow/ui/src/layouts/Details/DagVizModal.tsx 
b/airflow/ui/src/layouts/Details/DagVizModal.tsx
index 994511789f5..864be59ca5f 100644
--- a/airflow/ui/src/layouts/Details/DagVizModal.tsx
+++ b/airflow/ui/src/layouts/Details/DagVizModal.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 import { Button, Heading, HStack } from "@chakra-ui/react";
+import { useRef } from "react";
 import { FaChartGantt } from "react-icons/fa6";
 import { FiGrid } from "react-icons/fi";
 import { Link as RouterLink, useSearchParams } from "react-router-dom";
@@ -26,11 +27,12 @@ import { DagIcon } from "src/assets/DagIcon";
 import { Dialog } from "src/components/ui";
 import { capitalize } from "src/utils";
 
+import { DagRunSelect } from "./DagRunSelect";
 import { Gantt } from "./Gantt";
 import { Graph } from "./Graph";
 import { Grid } from "./Grid";
 
-type TriggerDAGModalProps = {
+type DAGVizModalProps = {
   dagDisplayName?: DAGResponse["dag_display_name"];
   dagId?: DAGResponse["dag_id"];
   onClose: () => void;
@@ -43,16 +45,17 @@ const visualizationOptions = [
     icon: <FaChartGantt height={5} width={5} />,
     value: "gantt",
   },
+  { component: <Grid />, icon: <FiGrid height={5} width={5} />, value: "grid" 
},
   {
     component: <Graph />,
     icon: <DagIcon height={5} width={5} />,
     value: "graph",
   },
-  { component: <Grid />, icon: <FiGrid height={5} width={5} />, value: "grid" 
},
 ];
 
-export const DagVizModal: React.FC<TriggerDAGModalProps> = ({ dagDisplayName, 
dagId, onClose, open }) => {
+export const DagVizModal: React.FC<DAGVizModalProps> = ({ dagDisplayName, 
dagId, onClose, open }) => {
   const [searchParams] = useSearchParams();
+  const contentRef = useRef<HTMLDivElement>(null);
 
   const activeViz = searchParams.get("modal") ?? "graph";
   const params = new URLSearchParams(searchParams);
@@ -60,9 +63,9 @@ export const DagVizModal: React.FC<TriggerDAGModalProps> = ({ 
dagDisplayName, da
   params.delete("modal");
 
   return (
-    <Dialog.Root onOpenChange={onClose} open={open} size="full">
-      <Dialog.Content backdrop>
-        <Dialog.Header bg="blue.muted">
+    <Dialog.Root motionPreset="none" onOpenChange={onClose} open={open} 
size="full">
+      <Dialog.Content backdrop ref={contentRef}>
+        <Dialog.Header bg="blue.muted" pr={16}>
           <HStack>
             <Heading mr={3} size="xl">
               {dagDisplayName ?? dagId}
@@ -85,6 +88,7 @@ export const DagVizModal: React.FC<TriggerDAGModalProps> = ({ 
dagDisplayName, da
                 </Button>
               </RouterLink>
             ))}
+            <DagRunSelect ref={contentRef} />
           </HStack>
           <Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
         </Dialog.Header>
diff --git a/airflow/ui/src/layouts/Details/Graph/Edge.tsx 
b/airflow/ui/src/layouts/Details/Graph/Edge.tsx
index 0e3c419f577..b7b19de1e49 100644
--- a/airflow/ui/src/layouts/Details/Graph/Edge.tsx
+++ b/airflow/ui/src/layouts/Details/Graph/Edge.tsx
@@ -30,7 +30,9 @@ type Props = EdgeType<EdgeData>;
 
 const CustomEdge = ({ data }: Props) => {
   const { colorMode } = useColorMode();
-  const [lightStroke, darkStroke] = useToken("colors", ["black", "gray.50"]);
+
+  // corresponds to the "border.inverted" semantic token
+  const [lightStroke, darkStroke] = useToken("colors", ["gray.800", 
"gray.200"]);
 
   if (data === undefined) {
     return undefined;
diff --git a/airflow/ui/src/layouts/Details/Graph/Graph.tsx 
b/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index 5c16ae41c54..f6972255f61 100644
--- a/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -16,20 +16,44 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Flex } from "@chakra-ui/react";
-import { ReactFlow, Controls, Background, MiniMap } from "@xyflow/react";
+import { Flex, useToken } from "@chakra-ui/react";
+import { ReactFlow, Controls, Background, MiniMap, type Node as ReactFlowNode 
} from "@xyflow/react";
 import "@xyflow/react/dist/style.css";
 import { useParams } from "react-router-dom";
 
-import { useStructureServiceStructureData } from "openapi/queries";
+import { useGridServiceGridData, useStructureServiceStructureData } from 
"openapi/queries";
 import { useColorMode } from "src/context/colorMode";
 import { useOpenGroups } from "src/context/openGroups";
+import { stateColor } from "src/utils/stateColor";
 
 import Edge from "./Edge";
 import { JoinNode } from "./JoinNode";
 import { TaskNode } from "./TaskNode";
+import type { CustomNodeProps } from "./reactflowUtils";
 import { useGraphLayout } from "./useGraphLayout";
 
+const nodeColor = (
+  { data: { depth, height, isOpen, taskInstance, width }, type }: 
ReactFlowNode<CustomNodeProps>,
+  evenColor?: string,
+  oddColor?: string,
+) => {
+  if (height === undefined || width === undefined || type === "join") {
+    return "";
+  }
+
+  if (taskInstance?.state !== undefined && !isOpen) {
+    return stateColor[taskInstance.state ?? "null"];
+  }
+
+  if (isOpen && depth !== undefined && depth % 2 === 0) {
+    return evenColor ?? "";
+  } else if (isOpen) {
+    return oddColor ?? "";
+  }
+
+  return "";
+};
+
 const nodeTypes = {
   join: JoinNode,
   task: TaskNode,
@@ -37,11 +61,23 @@ const nodeTypes = {
 const edgeTypes = { custom: Edge };
 
 export const Graph = () => {
-  const { colorMode } = useColorMode();
-  const { dagId = "" } = useParams();
+  const { colorMode = "light" } = useColorMode();
+  const { dagId = "", runId, taskId } = useParams();
+
+  // corresponds to the "bg", "bg.emphasized", "border.inverted" semantic 
tokens
+  const [oddLight, oddDark, evenLight, evenDark, selectedDarkColor, 
selectedLightColor] = useToken("colors", [
+    "white",
+    "black",
+    "gray.200",
+    "gray.800",
+    "gray.200",
+    "gray.800",
+  ]);
 
   const { openGroupIds } = useOpenGroups();
 
+  const selectedColor = colorMode === "dark" ? selectedDarkColor : 
selectedLightColor;
+
   const { data: graphData = { arrange: "LR", edges: [], nodes: [] } } = 
useStructureServiceStructureData({
     dagId,
   });
@@ -52,6 +88,38 @@ export const Graph = () => {
     openGroupIds,
   });
 
+  const { data: gridData } = useGridServiceGridData(
+    {
+      dagId,
+      limit: 14,
+      offset: 0,
+      orderBy: "-start_date",
+    },
+    undefined,
+    {
+      enabled: Boolean(runId),
+    },
+  );
+
+  const dagRun = gridData?.dag_runs.find((dr) => dr.dag_run_id === runId);
+
+  // Add task instances to the node data but without having to recalculate how 
the graph is laid out
+  const nodes =
+    dagRun?.task_instances === undefined
+      ? data?.nodes
+      : data?.nodes.map((node) => {
+          const taskInstance = dagRun.task_instances.find((ti) => ti.task_id 
=== node.id);
+
+          return {
+            ...node,
+            data: {
+              ...node.data,
+              isSelected: node.id === taskId,
+              taskInstance,
+            },
+          };
+        });
+
   return (
     <Flex flex={1}>
       <ReactFlow
@@ -63,14 +131,28 @@ export const Graph = () => {
         fitView
         maxZoom={1}
         minZoom={0.25}
-        nodes={data?.nodes ?? []}
+        nodes={nodes}
         nodesDraggable={false}
         nodeTypes={nodeTypes}
         onlyRenderVisibleElements
       >
         <Background />
         <Controls showInteractive={false} />
-        <MiniMap nodeStrokeWidth={15} pannable zoomable />
+        <MiniMap
+          nodeColor={(node: ReactFlowNode<CustomNodeProps>) =>
+            nodeColor(
+              node,
+              colorMode === "dark" ? evenDark : evenLight,
+              colorMode === "dark" ? oddDark : oddLight,
+            )
+          }
+          nodeStrokeColor={(node: ReactFlowNode<CustomNodeProps>) =>
+            node.data.isSelected && selectedColor !== undefined ? 
selectedColor : ""
+          }
+          nodeStrokeWidth={15}
+          pannable
+          zoomable
+        />
       </ReactFlow>
     </Flex>
   );
diff --git a/airflow/ui/src/layouts/Details/Graph/JoinNode.tsx 
b/airflow/ui/src/layouts/Details/Graph/JoinNode.tsx
index 60f774488da..7c1020bdaed 100644
--- a/airflow/ui/src/layouts/Details/Graph/JoinNode.tsx
+++ b/airflow/ui/src/layouts/Details/Graph/JoinNode.tsx
@@ -24,6 +24,11 @@ import type { CustomNodeProps } from "./reactflowUtils";
 
 export const JoinNode = ({ data }: NodeProps<NodeType<CustomNodeProps, 
"join">>) => (
   <NodeWrapper>
-    <Box bg="fg" borderRadius={`${data.width}px`} height={`${data.height}px`} 
width={`${data.width}px`} />
+    <Box
+      bg="border.inverted"
+      borderRadius={`${data.width}px`}
+      height={`${data.height}px`}
+      width={`${data.width}px`}
+    />
   </NodeWrapper>
 );
diff --git a/airflow/ui/src/layouts/Details/Graph/TaskName.tsx 
b/airflow/ui/src/layouts/Details/Graph/TaskName.tsx
index 7e0aaf16623..17af6183851 100644
--- a/airflow/ui/src/layouts/Details/Graph/TaskName.tsx
+++ b/airflow/ui/src/layouts/Details/Graph/TaskName.tsx
@@ -16,9 +16,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Text, type TextProps } from "@chakra-ui/react";
+import { type LinkProps, Link, Text } from "@chakra-ui/react";
 import type { CSSProperties } from "react";
 import { FiArrowUpRight, FiArrowDownRight } from "react-icons/fi";
+import { useParams, useSearchParams, Link as RouterLink } from 
"react-router-dom";
 
 import type { NodeResponse } from "openapi/requests/types.gen";
 
@@ -30,7 +31,13 @@ type Props = {
   readonly isZoomedOut?: boolean;
   readonly label: string;
   readonly setupTeardownType?: NodeResponse["setup_teardown_type"];
-} & TextProps;
+} & LinkProps;
+
+const iconStyle: CSSProperties = {
+  display: "inline",
+  position: "relative",
+  verticalAlign: "middle",
+};
 
 export const TaskName = ({
   id,
@@ -42,20 +49,34 @@ export const TaskName = ({
   setupTeardownType,
   ...rest
 }: Props) => {
-  const iconStyle: CSSProperties = {
-    display: "inline",
-    position: "relative",
-    verticalAlign: "middle",
-  };
+  const { dagId = "", runId, taskId } = useParams();
+  const [searchParams] = useSearchParams();
+
+  // We don't have a task group details page to link to
+  if (isGroup) {
+    return (
+      <Text fontSize="md" fontWeight="bold">
+        {label}
+      </Text>
+    );
+  }
 
   return (
-    <Text data-testid={id} fontSize={isZoomedOut ? 24 : undefined} {...rest}>
-      {label}
-      {isMapped ? " [ ]" : undefined}
-      {setupTeardownType === "setup" && <FiArrowUpRight size={isZoomedOut ? 24 
: 15} style={iconStyle} />}
-      {setupTeardownType === "teardown" && (
-        <FiArrowDownRight size={isZoomedOut ? 24 : 15} style={iconStyle} />
-      )}
-    </Text>
+    <Link asChild data-testid={id} fontSize={isZoomedOut ? "lg" : "md"} 
fontWeight="bold" {...rest}>
+      <RouterLink
+        to={{
+          // Do not include runId if there is no selected run, clicking a 
second time will deselect a task id
+          pathname: `/dags/${dagId}/${runId === undefined ? "" : 
`runs/${runId}/`}${taskId === id ? "" : `tasks/${id}`}`,
+          search: searchParams.toString(),
+        }}
+      >
+        {label}
+        {isMapped ? " [ ]" : undefined}
+        {setupTeardownType === "setup" && <FiArrowUpRight size={isZoomedOut ? 
24 : 15} style={iconStyle} />}
+        {setupTeardownType === "teardown" && (
+          <FiArrowDownRight size={isZoomedOut ? 24 : 15} style={iconStyle} />
+        )}
+      </RouterLink>
+    </Link>
   );
 };
diff --git a/airflow/ui/src/layouts/Details/Graph/TaskNode.tsx 
b/airflow/ui/src/layouts/Details/Graph/TaskNode.tsx
index ea590748210..1372e824a0c 100644
--- a/airflow/ui/src/layouts/Details/Graph/TaskNode.tsx
+++ b/airflow/ui/src/layouts/Details/Graph/TaskNode.tsx
@@ -16,11 +16,15 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Button, Flex, Text } from "@chakra-ui/react";
+import { Box, Button, Flex, HStack, Text } from "@chakra-ui/react";
 import type { NodeProps, Node as NodeType } from "@xyflow/react";
+import { MdRefresh } from "react-icons/md";
 
+import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
+import { Status } from "src/components/ui";
 import { useOpenGroups } from "src/context/openGroups";
 import { pluralize } from "src/utils";
+import { stateColor } from "src/utils/stateColor";
 
 import { NodeWrapper } from "./NodeWrapper";
 import { TaskName } from "./TaskName";
@@ -29,14 +33,16 @@ import type { CustomNodeProps } from "./reactflowUtils";
 export const TaskNode = ({
   data: {
     childCount,
+    depth,
     height,
     isGroup,
     isMapped,
     isOpen,
+    isSelected,
     label,
     operator,
     setupTeardownType,
-    type: nodeType,
+    taskInstance,
     width,
   },
   id,
@@ -50,43 +56,67 @@ export const TaskNode = ({
 
   return (
     <NodeWrapper>
-      <Flex alignItems="center" flexDirection="column">
-        <Flex
-          bg="bg"
-          borderColor="fg"
-          borderRadius={5}
-          borderWidth={1}
-          height={`${height}px`}
-          justifyContent="space-between"
-          px={3}
-          py={2}
-          width={`${width}px`}
+      <Flex alignItems="center" cursor="default" flexDirection="column">
+        <TaskInstanceTooltip
+          openDelay={500}
+          positioning={{
+            placement: "top-start",
+          }}
+          taskInstance={taskInstance}
         >
-          <Box>
-            <Text fontSize="xs" fontWeight="lighter" 
textTransform="capitalize">
-              {isGroup ? "Task Group" : operator}
-            </Text>
-            <Text color="blue.solid" textTransform="capitalize">
-              {nodeType}
-            </Text>
-            <TaskName
-              id={id}
-              isGroup={isGroup}
-              isMapped={isMapped}
-              isOpen={isOpen}
-              label={label}
-              setupTeardownType={setupTeardownType}
-            />
-          </Box>
-          <Box>
-            {isGroup ? (
-              <Button colorPalette="blue" onClick={onClick} p={0} 
variant="plain">
-                {isOpen ? "- " : "+ "}
-                {pluralize("task", childCount, undefined, false)}
-              </Button>
-            ) : undefined}
-          </Box>
-        </Flex>
+          <Flex
+            // Alternate background color for nested open groups
+            bg={isOpen && depth !== undefined && depth % 2 === 0 ? "bg.muted" 
: "bg"}
+            borderColor={
+              taskInstance?.state === undefined ? "border" : 
stateColor[taskInstance.state ?? "null"]
+            }
+            borderRadius={5}
+            borderWidth={isSelected ? 6 : 2}
+            height={`${height}px`}
+            justifyContent="space-between"
+            px={3}
+            py={isSelected ? 1 : 2}
+            width={`${width}px`}
+          >
+            <Box>
+              <TaskName
+                id={id}
+                isGroup={isGroup}
+                isMapped={isMapped}
+                isOpen={isOpen}
+                label={label}
+                setupTeardownType={setupTeardownType}
+              />
+              <Text color="fg.muted" fontSize="xs" mb={-1} mt={2} 
textTransform="capitalize">
+                {isGroup ? "Task Group" : operator}
+              </Text>
+              {taskInstance === undefined ? undefined : (
+                <HStack>
+                  <Status fontSize="xs" state={taskInstance.state}>
+                    {taskInstance.state}
+                  </Status>
+                  {taskInstance.try_number > 1 ? <MdRefresh /> : undefined}
+                </HStack>
+              )}
+            </Box>
+            <Box>
+              {isGroup ? (
+                <Button
+                  colorPalette="blue"
+                  cursor="pointer"
+                  height="inherit"
+                  onClick={onClick}
+                  pb={2}
+                  pr={0}
+                  variant="plain"
+                >
+                  {isOpen ? "- " : "+ "}
+                  {pluralize("task", childCount, undefined, false)}
+                </Button>
+              ) : undefined}
+            </Box>
+          </Flex>
+        </TaskInstanceTooltip>
         {Boolean(isMapped) || Boolean(isGroup && !isOpen) ? (
           <>
             <Box
diff --git a/airflow/ui/src/layouts/Details/Graph/reactflowUtils.ts 
b/airflow/ui/src/layouts/Details/Graph/reactflowUtils.ts
index 1d2aac791ee..b1e0b5cc0ca 100644
--- a/airflow/ui/src/layouts/Details/Graph/reactflowUtils.ts
+++ b/airflow/ui/src/layouts/Details/Graph/reactflowUtils.ts
@@ -19,20 +19,22 @@
 import type { Node as FlowNodeType, Edge as FlowEdgeType } from 
"@xyflow/react";
 import type { ElkExtendedEdge } from "elkjs";
 
-import type { NodeResponse } from "openapi/requests/types.gen";
+import type { GridTaskInstanceSummary, NodeResponse } from 
"openapi/requests/types.gen";
 
 import type { LayoutNode } from "./useGraphLayout";
 
 export type CustomNodeProps = {
   childCount?: number;
+  depth?: number;
   height?: number;
-  isActive?: boolean;
   isGroup?: boolean;
   isMapped?: boolean;
   isOpen?: boolean;
+  isSelected?: boolean;
   label: string;
   operator?: string | null;
   setupTeardownType?: NodeResponse["setup_teardown_type"];
+  taskInstance?: GridTaskInstanceSummary;
   type: string;
   width?: number;
 };
@@ -41,12 +43,14 @@ type NodeType = FlowNodeType<CustomNodeProps>;
 
 type FlattenNodesProps = {
   children?: Array<LayoutNode>;
+  level?: number;
   parent?: NodeType;
 };
 
 // Generate a flattened list of nodes for react-flow to render
 export const flattenGraph = ({
   children,
+  level = 0,
   parent,
 }: FlattenNodesProps): {
   edges: Array<ElkExtendedEdge>;
@@ -64,7 +68,7 @@ export const flattenGraph = ({
     const x = (parent?.position.x ?? 0) + (node.x ?? 0);
     const y = (parent?.position.y ?? 0) + (node.y ?? 0);
     const newNode = {
-      data: node,
+      data: { ...node, depth: level },
       id: node.id,
       position: {
         x,
@@ -107,6 +111,7 @@ export const flattenGraph = ({
     if (node.children) {
       const { edges: childEdges, nodes: childNodes } = flattenGraph({
         children: node.children as Array<LayoutNode>,
+        level: level + 1,
         parent: newNode,
       });
 
diff --git a/airflow/ui/src/layouts/Details/Graph/useGraphLayout.ts 
b/airflow/ui/src/layouts/Details/Graph/useGraphLayout.ts
index 2ecb06f7074..9093ac7a308 100644
--- a/airflow/ui/src/layouts/Details/Graph/useGraphLayout.ts
+++ b/airflow/ui/src/layouts/Details/Graph/useGraphLayout.ts
@@ -62,10 +62,12 @@ const getTextWidth = (text: string, font: string) => {
     context.font = font;
     const metrics = context.measureText(text);
 
-    return metrics.width;
+    return metrics.width > 200 ? metrics.width : 200;
   }
 
-  return text.length * 9;
+  const length = text.length * 9;
+
+  return length > 200 ? length : 200;
 };
 
 const getDirection = (arrange: string) => {
@@ -183,9 +185,8 @@ const generateElkGraph = ({
       closedGroupIds.push(node.id);
     }
 
-    const label = node.is_mapped ? `${node.label} [100]` : node.label;
-    const labelLength = getTextWidth(label, font);
-    let width = labelLength > 200 ? labelLength : 200;
+    const label = `${node.label}${node.is_mapped ? "[1000]" : 
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;
+    let width = getTextWidth(label, font);
     let height = 80;
 
     if (node.type === "join") {
@@ -235,7 +236,7 @@ type LayoutProps = {
 export const useGraphLayout = ({ arrange = "LR", dagId, edges, nodes, 
openGroupIds = [] }: LayoutProps) =>
   useQuery({
     queryFn: async () => {
-      const font = `bold 16px 
${globalThis.getComputedStyle(document.body).fontFamily}`;
+      const font = `bold 18px 
${globalThis.getComputedStyle(document.body).fontFamily}`;
       const elk = new ELK();
 
       // 1. Format graph data to pass for elk to process

Reply via email to