This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-1-test by this push:
new 642a0d64747 [v3-1-test] Add virtualization to grid view (#60241)
(#60285)
642a0d64747 is described below
commit 642a0d6474792c217f261996827c16bb9a3d8f26
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Jan 8 15:29:00 2026 -0500
[v3-1-test] Add virtualization to grid view (#60241) (#60285)
* Add virtualization to grid view
* Remove all jsx inline function declarations
* Remove awkward Bar code reuse, add nav mode const
* Remove extraneous code
* Update airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
* Fix scrollbars and horizontal scrolling
---------
(cherry picked from commit 7a64304948ac982950db4a6ecc10e0f90cbd4ede)
Co-authored-by: Brent Bovenzi <[email protected]>
Co-authored-by: Pierre Jeambrun <[email protected]>
---
.../src/airflow/ui/src/context/hover/Context.ts | 2 +
.../airflow/ui/src/context/hover/HoverProvider.tsx | 5 +-
.../src/airflow/ui/src/hooks/navigation/index.ts | 2 +
.../src/airflow/ui/src/hooks/navigation/types.ts | 8 +-
.../ui/src/hooks/navigation/useNavigation.ts | 35 +--
.../ui/src/layouts/Details/DetailsLayout.tsx | 2 +-
.../airflow/ui/src/layouts/Details/Grid/Bar.tsx | 31 ++-
.../airflow/ui/src/layouts/Details/Grid/Grid.tsx | 123 ++++++----
.../airflow/ui/src/layouts/Details/Grid/GridTI.tsx | 48 ++--
.../layouts/Details/Grid/TaskInstancesColumn.tsx | 111 +++++++--
.../ui/src/layouts/Details/Grid/TaskNames.tsx | 249 ++++++++++++---------
.../ui/src/layouts/Details/PanelButtons.tsx | 6 +-
12 files changed, 374 insertions(+), 248 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/context/hover/Context.ts
b/airflow-core/src/airflow/ui/src/context/hover/Context.ts
index e834b371702..d7c35c7e401 100644
--- a/airflow-core/src/airflow/ui/src/context/hover/Context.ts
+++ b/airflow-core/src/airflow/ui/src/context/hover/Context.ts
@@ -19,7 +19,9 @@
import { createContext } from "react";
export type HoverContextType = {
+ hoveredRunId: string | undefined;
hoveredTaskId: string | undefined;
+ setHoveredRunId: (runId: string | undefined) => void;
setHoveredTaskId: (taskId: string | undefined) => void;
};
diff --git a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
index e125ce28965..5bff0e92587 100644
--- a/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/hover/HoverProvider.tsx
@@ -22,14 +22,17 @@ import { useState, useMemo } from "react";
import { HoverContext } from "./Context";
export const HoverProvider = ({ children }: PropsWithChildren) => {
+ const [hoveredRunId, setHoveredRunId] = useState<string |
undefined>(undefined);
const [hoveredTaskId, setHoveredTaskId] = useState<string |
undefined>(undefined);
const value = useMemo(
() => ({
+ hoveredRunId,
hoveredTaskId,
+ setHoveredRunId,
setHoveredTaskId,
}),
- [hoveredTaskId],
+ [hoveredRunId, hoveredTaskId],
);
return <HoverContext.Provider
value={value}>{children}</HoverContext.Provider>;
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
b/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
index 0f9e9f69a23..05a81aed314 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
@@ -20,6 +20,8 @@
export { useNavigation } from "./useNavigation";
export { useKeyboardNavigation } from "./useKeyboardNavigation";
+export { NavigationModes } from "./types";
+
export type {
ArrowKey,
NavigationDirection,
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
b/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
index a3a7a1c9a31..af5a7eac8ae 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
@@ -19,7 +19,13 @@
import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";
-export type NavigationMode = "run" | "task" | "TI";
+export const NavigationModes = {
+ RUN: "run",
+ TASK: "task",
+ TI: "TI",
+} as const;
+
+export type NavigationMode = (typeof NavigationModes)[keyof typeof
NavigationModes];
export type ArrowKey = "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp";
diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
index 0c831ecb309..cab377b313d 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
@@ -23,36 +23,37 @@ import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";
import { buildTaskInstanceUrl } from "src/utils/links";
-import type {
- NavigationDirection,
- NavigationIndices,
- NavigationMode,
- UseNavigationProps,
- UseNavigationReturn,
+import {
+ NavigationModes,
+ type NavigationDirection,
+ type NavigationIndices,
+ type NavigationMode,
+ type UseNavigationProps,
+ type UseNavigationReturn,
} from "./types";
import { useKeyboardNavigation } from "./useKeyboardNavigation";
const detectModeFromUrl = (pathname: string): NavigationMode => {
if (pathname.includes("/runs/") && pathname.includes("/tasks/")) {
- return "TI";
+ return NavigationModes.TI;
}
if (pathname.includes("/runs/") && !pathname.includes("/tasks/")) {
- return "run";
+ return NavigationModes.RUN;
}
if (pathname.includes("/tasks/") && !pathname.includes("/runs/")) {
- return "task";
+ return NavigationModes.TASK;
}
- return "TI";
+ return NavigationModes.TI;
};
const isValidDirection = (direction: NavigationDirection, mode:
NavigationMode): boolean => {
switch (mode) {
- case "run":
+ case NavigationModes.RUN:
return direction === "left" || direction === "right";
- case "task":
+ case NavigationModes.TASK:
return direction === "down" || direction === "up";
- case "TI":
+ case NavigationModes.TI:
return true;
default:
return false;
@@ -74,11 +75,11 @@ const buildPath = (params: {
const groupPath = task.isGroup ? "group/" : "";
switch (mode) {
- case "run":
+ case NavigationModes.RUN:
return `/dags/${dagId}/runs/${run.run_id}`;
- case "task":
+ case NavigationModes.TASK:
return `/dags/${dagId}/tasks/${groupPath}${task.id}`;
- case "TI":
+ case NavigationModes.TI:
return buildTaskInstanceUrl({
currentPathname: pathname,
dagId,
@@ -98,7 +99,7 @@ export const useNavigation = ({ onToggleGroup, runs, tasks }:
UseNavigationProps
const enabled = Boolean(dagId) && (Boolean(runId) || Boolean(taskId) ||
Boolean(groupId));
const navigate = useNavigate();
const location = useLocation();
- const [mode, setMode] = useState<NavigationMode>("TI");
+ const [mode, setMode] = useState<NavigationMode>(NavigationModes.TI);
useEffect(() => {
const detectedMode = detectModeFromUrl(globalThis.location.pathname);
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 019e7cb5a54..9fbc27bb421 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -129,7 +129,7 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35
: 6}
order={1}
>
- <Box height="100%" marginInlineEnd={2} overflowY="auto"
paddingRight={4} position="relative">
+ <Box height="100%" position="relative">
<PanelButtons
dagView={dagView}
limit={limit}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
index 9360ace2256..946acc37387 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
@@ -17,39 +17,40 @@
* under the License.
*/
import { Flex, Box } from "@chakra-ui/react";
+import { useCallback } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import type { GridRunsResponse } from "openapi/requests";
import { RunTypeIcon } from "src/components/RunTypeIcon";
-import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
+import { useHover } from "src/context/hover";
import { GridButton } from "./GridButton";
-import { TaskInstancesColumn } from "./TaskInstancesColumn";
-import type { GridTask } from "./utils";
const BAR_HEIGHT = 100;
type Props = {
readonly max: number;
- readonly nodes: Array<GridTask>;
- readonly onCellClick?: () => void;
- readonly onColumnClick?: () => void;
+ readonly onClick?: () => void;
readonly run: GridRunsResponse;
};
-export const Bar = ({ max, nodes, onCellClick, onColumnClick, run }: Props) =>
{
+export const Bar = ({ max, onClick, run }: Props) => {
const { dagId = "", runId } = useParams();
const [searchParams] = useSearchParams();
+ const { hoveredRunId, setHoveredRunId } = useHover();
const isSelected = runId === run.run_id;
-
+ const isHovered = hoveredRunId === run.run_id;
const search = searchParams.toString();
- const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId:
run.run_id, state: run.state });
+
+ const handleMouseEnter = useCallback(() => setHoveredRunId(run.run_id),
[setHoveredRunId, run.run_id]);
+ const handleMouseLeave = useCallback(() => setHoveredRunId(undefined),
[setHoveredRunId]);
return (
<Box
- _hover={{ bg: "brand.subtle" }}
- bg={isSelected ? "brand.muted" : undefined}
+ bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" :
undefined}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
position="relative"
transition="background-color 0.2s"
>
@@ -57,7 +58,7 @@ export const Bar = ({ max, nodes, onCellClick, onColumnClick,
run }: Props) => {
alignItems="flex-end"
height={BAR_HEIGHT}
justifyContent="center"
- onClick={onColumnClick}
+ onClick={onClick}
pb="2px"
px="5px"
width="18px"
@@ -80,12 +81,6 @@ export const Bar = ({ max, nodes, onCellClick,
onColumnClick, run }: Props) => {
{run.run_type !== "scheduled" && <RunTypeIcon color="white"
runType={run.run_type} size="10px" />}
</GridButton>
</Flex>
- <TaskInstancesColumn
- nodes={nodes}
- onCellClick={onCellClick}
- runId={run.run_id}
- taskInstances={gridTISummaries?.task_instances ?? []}
- />
</Box>
);
};
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 c61a41a960a..eab0d4f3238 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
@@ -17,16 +17,17 @@
* under the License.
*/
import { Box, Flex, IconButton } from "@chakra-ui/react";
+import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiChevronsRight } from "react-icons/fi";
import { Link, useParams } from "react-router-dom";
import type { DagRunType, GridRunsResponse } from "openapi/requests";
import { useOpenGroups } from "src/context/openGroups";
-import { useNavigation } from "src/hooks/navigation";
+import { NavigationModes, useNavigation } from "src/hooks/navigation";
import { useGridRuns } from "src/queries/useGridRuns.ts";
import { useGridStructure } from "src/queries/useGridStructure.ts";
import { isStatePending } from "src/utils";
@@ -34,11 +35,14 @@ import { isStatePending } from "src/utils";
import { Bar } from "./Bar";
import { DurationAxis } from "./DurationAxis";
import { DurationTick } from "./DurationTick";
+import { TaskInstancesColumn } from "./TaskInstancesColumn";
import { TaskNames } from "./TaskNames";
import { flattenNodes } from "./utils";
dayjs.extend(dayjsDuration);
+const ROW_HEIGHT = 20;
+
type Props = {
readonly limit: number;
readonly runType?: DagRunType | undefined;
@@ -49,6 +53,7 @@ type Props = {
export const Grid = ({ limit, runType, showGantt, triggeringUser }: Props) => {
const { t: translate } = useTranslation("dag");
const gridRef = useRef<HTMLDivElement>(null);
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
const [selectedIsVisible, setSelectedIsVisible] = useState<boolean |
undefined>();
const { openGroupIds, toggleGroupId } = useOpenGroups();
@@ -93,61 +98,97 @@ export const Grid = ({ limit, runType, showGantt,
triggeringUser }: Props) => {
tasks: flatNodes,
});
+ const handleRowClick = useCallback(() => setMode(NavigationModes.TASK),
[setMode]);
+ const handleCellClick = useCallback(() => setMode(NavigationModes.TI),
[setMode]);
+ const handleColumnClick = useCallback(() => setMode(NavigationModes.RUN),
[setMode]);
+
+ const rowVirtualizer = useVirtualizer({
+ count: flatNodes.length,
+ estimateSize: () => ROW_HEIGHT,
+ getScrollElement: () => scrollContainerRef.current,
+ overscan: 5,
+ });
+
+ const virtualItems = rowVirtualizer.getVirtualItems();
+
return (
<Flex
+ flexDirection="column"
justifyContent="flex-start"
- outline="none"
position="relative"
- pt={20}
+ pt={16}
ref={gridRef}
tabIndex={0}
width={showGantt ? "1/2" : "full"}
>
- <Box display="flex" flexDirection="column" flexGrow={1}
justifyContent="end" minWidth="200px">
- <TaskNames nodes={flatNodes} onRowClick={() => setMode("task")} />
- </Box>
- <Box position="relative">
- <Flex position="relative">
- <DurationAxis top="100px" />
- <DurationAxis top="50px" />
- <DurationAxis top="4px" />
- <Flex flexDirection="column-reverse" height="100px"
position="relative">
- {Boolean(gridRuns?.length) && (
- <>
- <DurationTick bottom="92px" duration={max} />
- <DurationTick bottom="46px" duration={max / 2} />
- <DurationTick bottom="-4px" duration={0} />
- </>
- )}
+ {/* Grid scroll container */}
+ <Box
+ height="calc(100vh - 140px)"
+ marginRight={1}
+ overflow="auto"
+ paddingRight={4}
+ position="relative"
+ ref={scrollContainerRef}
+ >
+ {/* Grid header, both bgs are needed to hide elements during
horizontal and vertical scroll */}
+ <Flex bg="bg" display="flex" position="sticky" pt={2} top={0}
zIndex={2}>
+ <Box bg="bg" flexGrow={1} left={0} minWidth="200px"
position="sticky" zIndex={1}>
+ <Flex flexDirection="column-reverse" height="100px"
position="relative">
+ {Boolean(gridRuns?.length) && (
+ <>
+ <DurationTick bottom="92px" duration={max} />
+ <DurationTick bottom="46px" duration={max / 2} />
+ </>
+ )}
+ </Flex>
+ </Box>
+ {/* Duration bars */}
+ <Flex flexDirection="row-reverse" flexShrink={0}>
+ <Flex flexShrink={0} position="relative">
+ <DurationAxis top="100px" />
+ <DurationAxis top="50px" />
+ <DurationAxis top="4px" />
+ <Flex flexDirection="row-reverse">
+ {gridRuns?.map((dr: GridRunsResponse) => (
+ <Bar key={dr.run_id} max={max} onClick={handleColumnClick}
run={dr} />
+ ))}
+ </Flex>
+ {selectedIsVisible === undefined || !selectedIsVisible ?
undefined : (
+ <Link to={`/dags/${dagId}`}>
+ <IconButton
+ aria-label={translate("grid.buttons.resetToLatest")}
+ height="98px"
+ loading={isLoading}
+ minW={0}
+ ml={1}
+ title={translate("grid.buttons.resetToLatest")}
+ variant="surface"
+ zIndex={1}
+ >
+ <FiChevronsRight />
+ </IconButton>
+ </Link>
+ )}
+ </Flex>
</Flex>
- <Flex flexDirection="row-reverse">
+ </Flex>
+
+ {/* Grid body */}
+ <Flex height={`${rowVirtualizer.getTotalSize()}px`}
position="relative">
+ <Box bg="bg" flexGrow={1} flexShrink={0} left={0} minWidth="200px"
position="sticky" zIndex={1}>
+ <TaskNames nodes={flatNodes} onRowClick={handleRowClick}
virtualItems={virtualItems} />
+ </Box>
+ <Flex flexDirection="row-reverse" flexShrink={0}>
{gridRuns?.map((dr: GridRunsResponse) => (
- <Bar
+ <TaskInstancesColumn
key={dr.run_id}
- max={max}
nodes={flatNodes}
- onCellClick={() => setMode("TI")}
- onColumnClick={() => setMode("run")}
+ onCellClick={handleCellClick}
run={dr}
+ virtualItems={virtualItems}
/>
))}
</Flex>
- {selectedIsVisible === undefined || !selectedIsVisible ? undefined :
(
- <Link to={`/dags/${dagId}`}>
- <IconButton
- aria-label={translate("grid.buttons.resetToLatest")}
- height="98px"
- loading={isLoading}
- minW={0}
- ml={1}
- title={translate("grid.buttons.resetToLatest")}
- variant="surface"
- zIndex={1}
- >
- <FiChevronsRight />
- </IconButton>
- </Link>
- )}
</Flex>
</Box>
</Flex>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index d4d775da977..cea28632947 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -17,8 +17,7 @@
* under the License.
*/
import { Badge, Flex } from "@chakra-ui/react";
-import type { MouseEvent } from "react";
-import React, { useCallback } from "react";
+import React, { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useParams, useSearchParams } from
"react-router-dom";
@@ -26,30 +25,9 @@ import type { LightGridTaskInstanceSummary } from
"openapi/requests/types.gen";
import { BasicTooltip } from "src/components/BasicTooltip";
import { StateIcon } from "src/components/StateIcon";
import Time from "src/components/Time";
-import { type HoverContextType, useHover } from "src/context/hover";
+import { useHover } from "src/context/hover";
import { buildTaskInstanceUrl } from "src/utils/links";
-const handleMouseEnter =
- (setHoveredTaskId: HoverContextType["setHoveredTaskId"]) => (event:
MouseEvent<HTMLDivElement>) => {
- const tasks =
document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`);
-
- tasks.forEach((task) => {
- task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
- });
-
- setHoveredTaskId(event.currentTarget.id.replaceAll("-", "."));
- };
-
-const handleMouseLeave = (taskId: string, setHoveredTaskId:
HoverContextType["setHoveredTaskId"]) => () => {
- const tasks =
document.querySelectorAll<HTMLDivElement>(`#task-${taskId.replaceAll(".",
"-")}`);
-
- tasks.forEach((task) => {
- task.style.backgroundColor = "";
- });
-
- setHoveredTaskId(undefined);
-};
-
type Props = {
readonly dagId: string;
readonly instance: LightGridTaskInstanceSummary;
@@ -62,17 +40,14 @@ type Props = {
};
const Instance = ({ dagId, instance, isGroup, isMapped, onClick, runId, taskId
}: Props) => {
- const { setHoveredTaskId } = useHover();
+ const { hoveredTaskId, setHoveredTaskId } = useHover();
const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
const { t: translate } = useTranslation();
const location = useLocation();
const [searchParams] = useSearchParams();
- const onMouseEnter = handleMouseEnter(setHoveredTaskId);
- const onMouseLeave = handleMouseLeave(taskId, setHoveredTaskId);
-
- const getTaskUrl = useCallback(
+ const taskUrl = useMemo(
() =>
buildTaskInstanceUrl({
currentPathname: location.pathname,
@@ -85,22 +60,29 @@ const Instance = ({ dagId, instance, isGroup, isMapped,
onClick, runId, taskId }
[dagId, isGroup, isMapped, location.pathname, runId, taskId],
);
+ const handleMouseEnter = useCallback(() => setHoveredTaskId(taskId),
[setHoveredTaskId, taskId]);
+ const handleMouseLeave = useCallback(() => setHoveredTaskId(undefined),
[setHoveredTaskId]);
+
// Remove try_number query param when navigating to reset to the
// latest try of the task instance and avoid issues with invalid try numbers:
// https://github.com/apache/airflow/issues/56977
searchParams.delete("try_number");
const redirectionSearch = searchParams.toString();
+ // Determine background: selected takes priority over hovered
+ const isSelected = selectedTaskId === taskId || selectedGroupId === taskId;
+ const isHovered = hoveredTaskId === taskId;
+
return (
<Flex
alignItems="center"
- bg={selectedTaskId === taskId || selectedGroupId === taskId ?
"info.muted" : undefined}
+ bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" :
undefined}
height="20px"
id={`task-${taskId.replaceAll(".", "-")}`}
justifyContent="center"
key={taskId}
- onMouseEnter={onMouseEnter}
- onMouseLeave={onMouseLeave}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
position="relative"
px="2px"
py={0}
@@ -135,7 +117,7 @@ const Instance = ({ dagId, instance, isGroup, isMapped,
onClick, runId, taskId }
onClick={onClick}
replace
to={{
- pathname: getTaskUrl(),
+ pathname: taskUrl,
search: redirectionSearch,
}}
>
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index c312af33dab..399fbca583a 100644
---
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -17,44 +17,107 @@
* under the License.
*/
import { Box } from "@chakra-ui/react";
+import type { VirtualItem } from "@tanstack/react-virtual";
+import { memo, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
+import type { GridRunsResponse } from "openapi/requests";
import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { useHover } from "src/context/hover";
+import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
import { GridTI } from "./GridTI";
import type { GridTask } from "./utils";
type Props = {
- readonly depth?: number;
readonly nodes: Array<GridTask>;
readonly onCellClick?: () => void;
- readonly runId: string;
- readonly taskInstances: Array<LightGridTaskInstanceSummary>;
+ readonly run: GridRunsResponse;
+ readonly virtualItems?: Array<VirtualItem>;
};
-export const TaskInstancesColumn = ({ nodes, onCellClick, runId, taskInstances
}: Props) => {
- const { dagId = "" } = useParams();
+const ROW_HEIGHT = 20;
- return nodes.map((node) => {
- // todo: how does this work with mapped? same task id for multiple tis
- const taskInstance = taskInstances.find((ti) => ti.task_id === node.id);
+const TaskInstancesColumnInner = ({ nodes, onCellClick, run, virtualItems }:
Props) => {
+ const { dagId = "", runId } = useParams();
+ const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId:
run.run_id, state: run.state });
+ const { hoveredRunId, setHoveredRunId } = useHover();
- if (!taskInstance) {
- return <Box height="20px" key={`${node.id}-${runId}`} width="18px" />;
+ const itemsToRender =
+ virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start:
index * ROW_HEIGHT }));
+
+ const taskInstanceMap = useMemo(() => {
+ const taskInstances = gridTISummaries?.task_instances ?? [];
+ const map = new Map<string, LightGridTaskInstanceSummary>();
+
+ for (const ti of taskInstances) {
+ map.set(ti.task_id, ti);
}
- return (
- <GridTI
- dagId={dagId}
- instance={taskInstance}
- isGroup={node.isGroup}
- isMapped={node.is_mapped}
- key={node.id}
- label={node.label}
- onClick={onCellClick}
- runId={runId}
- taskId={node.id}
- />
- );
- });
+ return map;
+ }, [gridTISummaries?.task_instances]);
+
+ const isSelected = runId === run.run_id;
+ const isHovered = hoveredRunId === run.run_id;
+
+ const handleMouseEnter = useCallback(() => setHoveredRunId(run.run_id),
[setHoveredRunId, run.run_id]);
+ const handleMouseLeave = useCallback(() => setHoveredRunId(undefined),
[setHoveredRunId]);
+
+ return (
+ <Box
+ bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" :
undefined}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ position="relative"
+ transition="background-color 0.2s"
+ width="18px"
+ >
+ {itemsToRender.map((virtualItem) => {
+ const node = nodes[virtualItem.index];
+
+ if (!node) {
+ return undefined;
+ }
+
+ const taskInstance = taskInstanceMap.get(node.id);
+
+ if (!taskInstance) {
+ return (
+ <Box
+ height={`${ROW_HEIGHT}px`}
+ key={`${node.id}-${run.run_id}`}
+ left={0}
+ position="absolute"
+ top={0}
+ transform={`translateY(${virtualItem.start}px)`}
+ width="18px"
+ />
+ );
+ }
+
+ return (
+ <Box
+ key={node.id}
+ left={0}
+ position="absolute"
+ top={0}
+ transform={`translateY(${virtualItem.start}px)`}
+ >
+ <GridTI
+ dagId={dagId}
+ instance={taskInstance}
+ isGroup={node.isGroup}
+ isMapped={node.is_mapped}
+ label={node.label}
+ onClick={onCellClick}
+ runId={run.run_id}
+ taskId={node.id}
+ />
+ </Box>
+ );
+ })}
+ </Box>
+ );
};
+
+export const TaskInstancesColumn = memo(TaskInstancesColumnInner);
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
index 21b4c6af054..85f7fc1c788 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
@@ -17,137 +17,168 @@
* under the License.
*/
import { Box, chakra, Flex, Link } from "@chakra-ui/react";
+import type { VirtualItem } from "@tanstack/react-virtual";
import type { MouseEvent } from "react";
+import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FiChevronUp } from "react-icons/fi";
import { Link as RouterLink, useParams, useSearchParams } from
"react-router-dom";
import { TaskName } from "src/components/TaskName";
-import { type HoverContextType, useHover } from "src/context/hover";
+import { useHover } from "src/context/hover";
import { useOpenGroups } from "src/context/openGroups";
import type { GridTask } from "./utils";
type Props = {
- depth?: number;
- nodes: Array<GridTask>;
- onRowClick?: () => void;
+ readonly depth?: number;
+ readonly nodes: Array<GridTask>;
+ readonly onRowClick?: () => void;
+ readonly virtualItems?: Array<VirtualItem>;
};
+const ROW_HEIGHT = 20;
+
const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`;
-const onMouseEnter = (
- event: MouseEvent<HTMLDivElement>,
- nodeId: string,
- setHoveredTaskId: HoverContextType["setHoveredTaskId"],
-) => {
- const tasks =
document.querySelectorAll<HTMLDivElement>(`#${event.currentTarget.id}`);
+export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => {
+ const { t: translate } = useTranslation("dag");
+ const { hoveredTaskId, setHoveredTaskId } = useHover();
+ const { toggleGroupId } = useOpenGroups();
+ const { dagId = "", groupId, taskId } = useParams();
+ const [searchParams] = useSearchParams();
- tasks.forEach((task) => {
- task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
- });
+ const handleMouseEnter = useCallback(
+ (event: MouseEvent<HTMLDivElement>) => {
+ const { nodeId } = event.currentTarget.dataset;
- setHoveredTaskId(nodeId);
-};
+ if (nodeId !== undefined) {
+ setHoveredTaskId(nodeId);
+ }
+ },
+ [setHoveredTaskId],
+ );
-const onMouseLeave = (nodeId: string, setHoveredTaskId:
HoverContextType["setHoveredTaskId"]) => {
- const tasks =
document.querySelectorAll<HTMLDivElement>(`#task-${nodeId.replaceAll(".",
"-")}`);
+ const handleMouseLeave = useCallback(() => setHoveredTaskId(undefined),
[setHoveredTaskId]);
- tasks.forEach((task) => {
- task.style.backgroundColor = "";
- });
+ const handleToggleGroup = useCallback(
+ (event: MouseEvent<HTMLSpanElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const groupNodeId = event.currentTarget.dataset.groupId;
- setHoveredTaskId(undefined);
-};
+ if (groupNodeId !== undefined) {
+ toggleGroupId(groupNodeId);
+ }
+ },
+ [toggleGroupId],
+ );
-export const TaskNames = ({ nodes, onRowClick }: Props) => {
- const { t: translate } = useTranslation("dag");
- const { setHoveredTaskId } = useHover();
- const { toggleGroupId } = useOpenGroups();
- const { dagId = "", groupId, taskId } = useParams();
- const [searchParams] = useSearchParams();
+ const search = searchParams.toString();
+
+ // If virtualItems is provided, use virtualization; otherwise render all
items
+ const itemsToRender =
+ virtualItems ?? nodes.map((_, index) => ({ index, size: ROW_HEIGHT, start:
index * ROW_HEIGHT }));
+
+ return (
+ <>
+ {itemsToRender.map((virtualItem) => {
+ const node = nodes[virtualItem.index];
- return nodes.map((node, index) => (
- <Box
- bg={node.id === taskId || node.id === groupId ? "info.muted" : undefined}
- borderBottomWidth={1}
- borderColor={node.isGroup ? "border.emphasized" : "border"}
- borderTopWidth={index === 0 ? 1 : 0}
- cursor="pointer"
- id={`task-${node.id.replaceAll(".", "-")}`}
- key={node.id}
- maxHeight="20px"
- onMouseEnter={(event) => onMouseEnter(event, node.id, setHoveredTaskId)}
- onMouseLeave={() => onMouseLeave(node.id, setHoveredTaskId)}
- transition="background-color 0.2s"
- >
- {node.isGroup ? (
- <Link asChild data-testid={node.id} display="block" width="100%">
- <RouterLink
- onClick={onRowClick}
- replace
- style={{ outline: "none" }}
- to={{
- pathname: `/dags/${dagId}/tasks/group/${node.id}`,
- search: searchParams.toString(),
- }}
+ if (!node) {
+ return undefined;
+ }
+
+ const isSelected = node.id === taskId || node.id === groupId;
+ const isHovered = hoveredTaskId === node.id;
+
+ return (
+ <Box
+ bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" :
undefined}
+ borderBottomWidth={1}
+ borderColor={node.isGroup ? "border.emphasized" : "border"}
+ borderTopWidth={virtualItem.index === 0 ? 1 : 0}
+ cursor="pointer"
+ data-node-id={node.id}
+ height={`${ROW_HEIGHT}px`}
+ id={`task-${node.id.replaceAll(".", "-")}`}
+ key={node.id}
+ left={0}
+ onMouseEnter={handleMouseEnter}
+ onMouseLeave={handleMouseLeave}
+ position="absolute"
+ right={0}
+ top={0}
+ transform={`translateY(${virtualItem.start}px)`}
+ transition="background-color 0.2s"
>
- <Flex alignItems="center" width="100%">
- <TaskName
- fontSize="sm"
- fontWeight="normal"
- isGroup={true}
- isMapped={Boolean(node.is_mapped)}
- label={node.label}
- paddingLeft={indent(node.depth)}
- setupTeardownType={node.setup_teardown_type}
- />
- <chakra.span
- _focus={{ outline: "none" }}
- alignItems="center"
- aria-label={translate("grid.buttons.toggleGroup")}
- cursor="pointer"
- display="inline-flex"
- ml={1}
- onClick={(event) => {
- event.preventDefault();
- event.stopPropagation();
- toggleGroupId(node.id);
- }}
- px={1}
- >
- <FiChevronUp
- size={16}
- style={{
- transform: `rotate(${node.isOpen ? 0 : 180}deg)`,
- transition: "transform 0.5s",
+ {node.isGroup ? (
+ <Link asChild data-testid={node.id} display="block" width="100%">
+ <RouterLink
+ onClick={onRowClick}
+ replace
+ style={{ outline: "none" }}
+ to={{
+ pathname: `/dags/${dagId}/tasks/group/${node.id}`,
+ search,
}}
- />
- </chakra.span>
- </Flex>
- </RouterLink>
- </Link>
- ) : (
- <Link asChild data-testid={node.id} display="inline">
- <RouterLink
- onClick={onRowClick}
- replace
- to={{
- pathname: `/dags/${dagId}/tasks/${node.id}`,
- search: searchParams.toString(),
- }}
- >
- <TaskName
- fontSize="sm"
- fontWeight="normal"
- isMapped={Boolean(node.is_mapped)}
- label={node.label}
- paddingLeft={indent(node.depth)}
- setupTeardownType={node.setup_teardown_type}
- />
- </RouterLink>
- </Link>
- )}
- </Box>
- ));
+ >
+ <Flex alignItems="center" width="100%">
+ <TaskName
+ fontSize="sm"
+ fontWeight="normal"
+ isGroup={true}
+ isMapped={Boolean(node.is_mapped)}
+ label={node.label}
+ paddingLeft={indent(node.depth)}
+ setupTeardownType={node.setup_teardown_type}
+ />
+ <chakra.span
+ _focus={{ outline: "none" }}
+ alignItems="center"
+ aria-label={translate("grid.buttons.toggleGroup")}
+ cursor="pointer"
+ data-group-id={node.id}
+ display="inline-flex"
+ ml={1}
+ onClick={handleToggleGroup}
+ px={1}
+ >
+ <FiChevronUp
+ size={16}
+ style={{
+ transform: `rotate(${node.isOpen ? 0 : 180}deg)`,
+ transition: "transform 0.5s",
+ }}
+ />
+ </chakra.span>
+ </Flex>
+ </RouterLink>
+ </Link>
+ ) : (
+ <Link asChild data-testid={node.id} display="inline">
+ <RouterLink
+ onClick={onRowClick}
+ replace
+ to={{
+ pathname: `/dags/${dagId}/tasks/${node.id}`,
+ search,
+ }}
+ >
+ <TaskName
+ fontSize="sm"
+ fontWeight="normal"
+ isMapped={Boolean(node.is_mapped)}
+ label={node.label}
+ paddingLeft={indent(node.depth)}
+ setupTeardownType={node.setup_teardown_type}
+ />
+ </RouterLink>
+ </Link>
+ )}
+ </Box>
+ );
+ })}
+ </>
+ );
};
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 1ac9cbaae59..7db78c9d7ef 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -208,8 +208,8 @@ export const PanelButtons = ({
);
return (
- <Box position="absolute" px={2} ref={containerRef} top={1} width="100%"
zIndex={1}>
- <Flex justifyContent="space-between" pl={2}>
+ <Box position="absolute" pr={4} ref={containerRef} top={1} width="100%"
zIndex={1}>
+ <Flex justifyContent="space-between">
<ButtonGroup attached size="sm" variant="outline">
<IconButton
aria-label={translate("dag:panel.buttons.showGridShortcut")}
@@ -242,7 +242,7 @@ export const PanelButtons = ({
<MdOutlineAccountTree />
</IconButton>
</ButtonGroup>
- <Flex alignItems="center" gap={1} justifyContent="space-between"
pl={2} pr={6}>
+ <Flex alignItems="center" gap={1} justifyContent="space-between">
<ToggleGroups />
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Popover.Root autoFocus={false} positioning={{ placement:
"bottom-end" }}>