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