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 075f710b10fea..0a581fa881ddc 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -18,10 +18,10 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, HStack, Flex, useDisclosure, IconButton } from "@chakra-ui/react"; +import { Box, Flex, HStack, IconButton, useDisclosure } from "@chakra-ui/react"; import { useReactFlow } from "@xyflow/react"; import { useRef, useState } from "react"; -import type { PropsWithChildren, ReactNode } from "react"; +import type { PropsWithChildren, ReactNode, RefObject } from "react"; import { useTranslation } from "react-i18next"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa"; import { LuFileWarning } from "react-icons/lu"; @@ -51,11 +51,10 @@ import { runAfterGteKey, runAfterLteKey, runTypeFilterKey, - showGanttKey, triggeringUserFilterKey, } from "src/constants/localStorage"; import { VersionIndicatorOptions } from "src/constants/showVersionIndicatorOptions"; -import { HoverProvider } from "src/context/hover"; +import { HoverProvider, useHover } from "src/context/hover"; import { OpenGroupsProvider } from "src/context/openGroups"; import { DagBreadcrumb } from "./DagBreadcrumb"; @@ -65,6 +64,33 @@ import { Grid } from "./Grid"; import { NavTabs } from "./NavTabs"; import { PanelButtons } from "./PanelButtons"; +// Separate component so useHover can be called inside HoverProvider. +const SharedScrollBox = ({ + children, + scrollRef, +}: { + readonly children: ReactNode; + readonly scrollRef: RefObject; +}) => { + const { setHoveredTaskId } = useHover(); + + return ( + setHoveredTaskId(undefined)} + overflowX="hidden" + overflowY="auto" + ref={scrollRef} + style={{ scrollbarGutter: "stable" }} + w="100%" + > + {children} + + ); +}; + type Props = { readonly error?: unknown; readonly isLoading?: boolean; @@ -77,7 +103,10 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const { data: dag } = useDagServiceGetDag({ dagId }); const [defaultDagView] = useLocalStorage<"graph" | "grid">(DEFAULT_DAG_VIEW_KEY, "grid"); const panelGroupRef = useRef(null); - const [dagView, setDagView] = useLocalStorage<"graph" | "grid">(dagViewKey(dagId), defaultDagView); + const [dagView, setDagView] = useLocalStorage<"gantt" | "graph" | "grid">( + dagViewKey(dagId), + defaultDagView, + ); const [limit, setLimit] = useLocalStorage(dagRunsLimitKey(dagId), 10); const [runAfterGte, setRunAfterGte] = useLocalStorage(runAfterGteKey(dagId), undefined); const [runAfterLte, setRunAfterLte] = useLocalStorage(runAfterLteKey(dagId), undefined); @@ -94,7 +123,6 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { undefined, ); - const [showGantt, setShowGantt] = useLocalStorage(showGanttKey(dagId), false); // Global setting: applies to all Dags (intentionally not scoped to dagId) const [showVersionIndicatorMode, setShowVersionIndicatorMode] = useLocalStorage( `version_indicator_display_mode`, @@ -106,6 +134,9 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false); const { i18n } = useTranslation(); const direction = i18n.dir(); + const sharedGridGanttScrollRef = useRef(null); + // Treat "gantt" as "grid" for panel layout persistence so switching between them doesn't reset sizes. + const panelViewKey = dagView === "gantt" ? "grid" : dagView; return ( @@ -149,19 +180,19 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { ) : undefined} - + { setRunAfterGte={setRunAfterGte} setRunAfterLte={setRunAfterLte} setRunTypeFilter={setRunTypeFilter} - setShowGantt={setShowGantt} setShowVersionIndicatorMode={setShowVersionIndicatorMode} setTriggeringUserFilter={setTriggeringUserFilter} - showGantt={showGantt} showVersionIndicatorMode={showVersionIndicatorMode} triggeringUserFilter={triggeringUserFilter} /> - {dagView === "graph" ? ( - - ) : ( - - - {showGantt ? ( - + {dagView === "graph" ? ( + + ) : dagView === "gantt" && Boolean(runId) ? ( + + + + + + + ) : ( + + - ) : undefined} - - )} - + + )} + + {!isRightPanelCollapsed && ( <> @@ -230,7 +283,6 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { justifyContent="center" position="relative" w={0.5} - // onClick={(e) => console.log(e)} /> diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx index 71d14ccfbe78b..0c7f280d88fce 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx @@ -16,80 +16,52 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, useToken } from "@chakra-ui/react"; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - BarElement, - Filler, - Title, - Tooltip, - Legend, - TimeScale, -} from "chart.js"; -import "chart.js/auto"; -import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm"; -import annotationPlugin from "chartjs-plugin-annotation"; -import { useDeferredValue } from "react"; -import { Bar } from "react-chartjs-2"; -import { useTranslation } from "react-i18next"; -import { useParams, useNavigate, useLocation, useSearchParams } from "react-router-dom"; +import { Box, Flex } from "@chakra-ui/react"; +import { useRef } from "react"; +import type { RefObject } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; import { useGanttServiceGetGanttData } from "openapi/queries"; import type { DagRunState, DagRunType } from "openapi/requests/types.gen"; -import { useColorMode } from "src/context/colorMode"; import { useHover } from "src/context/hover"; import { useOpenGroups } from "src/context/openGroups"; import { useTimezone } from "src/context/timezone"; -import { GRID_BODY_OFFSET_PX } from "src/layouts/Details/Grid/constants"; +import { NavigationModes, useNavigation } from "src/hooks/navigation"; +import { + GANTT_AXIS_HEIGHT_PX, + GANTT_ROW_OFFSET_PX, + GANTT_TOP_PADDING_PX, +} from "src/layouts/Details/Grid/constants"; import { flattenNodes } from "src/layouts/Details/Grid/utils"; import { useGridRuns } from "src/queries/useGridRuns"; import { useGridStructure } from "src/queries/useGridStructure"; import { useGridTiSummariesStream } from "src/queries/useGridTISummaries"; -import { getComputedCSSVariableValue } from "src/theme"; import { isStatePending, useAutoRefresh } from "src/utils"; -import { createHandleBarClick, createHandleBarHover, createChartOptions, transformGanttData } from "./utils"; - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - BarElement, - LineElement, - Filler, - Title, - Tooltip, - Legend, - annotationPlugin, - TimeScale, -); +import { GanttTimeline } from "./GanttTimeline"; +import { buildGanttRowSegments, computeGanttTimeRangeMs, transformGanttData } from "./utils"; + +const GANTT_STANDALONE_VIRTUALIZER_PADDING_START_PX = GANTT_TOP_PADDING_PX + GANTT_AXIS_HEIGHT_PX; type Props = { readonly dagRunState?: DagRunState | undefined; readonly limit: number; readonly runType?: DagRunType | undefined; + readonly sharedScrollContainerRef?: RefObject; readonly triggeringUser?: string | undefined; }; -const CHART_PADDING = 36; -const CHART_ROW_HEIGHT = 20; -const MIN_BAR_WIDTH = 10; +export const Gantt = ({ dagRunState, limit, runType, sharedScrollContainerRef, triggeringUser }: Props) => { + const standaloneScrollRef = useRef(null); + const usesSharedScroll = sharedScrollContainerRef !== undefined; -export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => { - const { dagId = "", groupId: selectedGroupId, runId = "", taskId: selectedTaskId } = useParams(); + const scrollContainerRef = usesSharedScroll ? sharedScrollContainerRef : standaloneScrollRef; + + const { dagId = "", runId = "" } = useParams(); const [searchParams] = useSearchParams(); - const { openGroupIds } = useOpenGroups(); - const deferredOpenGroupIds = useDeferredValue(openGroupIds); - const { t: translate } = useTranslation("common"); + const { openGroupIds, toggleGroupId } = useOpenGroups(); const { selectedTimezone } = useTimezone(); - const { colorMode } = useColorMode(); - const { hoveredTaskId, setHoveredTaskId } = useHover(); - const navigate = useNavigate(); - const location = useLocation(); + const { setHoveredTaskId } = useHover(); const filterRoot = searchParams.get("root") ?? undefined; const includeUpstream = searchParams.get("upstream") === "true"; @@ -97,20 +69,6 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => const depthParam = searchParams.get("depth"); const depth = depthParam !== null && depthParam !== "" ? parseInt(depthParam, 10) : undefined; - // Corresponds to border, brand.emphasized, and brand.muted - const [ - lightGridColor, - darkGridColor, - lightSelectedColor, - darkSelectedColor, - lightHoverColor, - darkHoverColor, - ] = useToken("colors", ["gray.200", "gray.800", "brand.300", "brand.700", "brand.200", "brand.800"]); - - const gridColor = colorMode === "light" ? lightGridColor : darkGridColor; - const selectedItemColor = colorMode === "light" ? lightSelectedColor : darkSelectedColor; - const hoveredItemColor = colorMode === "light" ? lightHoverColor : darkHoverColor; - const { data: gridRuns, isLoading: runsLoading } = useGridRuns({ dagRunState, limit, @@ -130,7 +88,6 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => const selectedRun = gridRuns?.find((run) => run.run_id === runId); const refetchInterval = useAutoRefresh({ dagId }); - // Get grid summaries for groups and mapped tasks (which have min/max times) const { summariesByRunId } = useGridTiSummariesStream({ dagId, runIds: runId && selectedRun ? [runId] : [], @@ -139,7 +96,6 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => const gridTiSummaries = summariesByRunId.get(runId); const summariesLoading = Boolean(runId && selectedRun && !summariesByRunId.has(runId)); - // Single fetch for all Gantt data (individual task tries) const { data: ganttData, isLoading: ganttLoading } = useGanttServiceGetGanttData( { dagId, runId }, undefined, @@ -150,95 +106,80 @@ export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) => }, ); - const { flatNodes } = flattenNodes(dagStructure, deferredOpenGroupIds); + const { flatNodes } = flattenNodes(dagStructure, openGroupIds); + + const { setMode } = useNavigation({ + onToggleGroup: toggleGroupId, + runs: gridRuns ?? [], + tasks: flatNodes, + }); const isLoading = runsLoading || structureLoading || summariesLoading || ganttLoading; const allTries = ganttData?.task_instances ?? []; const gridSummaries = gridTiSummaries?.task_instances ?? []; - const data = isLoading || runId === "" ? [] : transformGanttData({ allTries, flatNodes, gridSummaries }); - - const labels = flatNodes.map((node) => node.id); + const ganttDataItems = + isLoading || runId === "" ? [] : transformGanttData({ allTries, flatNodes, gridSummaries }); - // Get all unique states and their colors - const states = [...new Set(data.map((item) => item.state ?? "none"))]; - const stateColorTokens = useToken( - "colors", - states.map((state) => `${state}.solid`), - ); - const stateColorMap = Object.fromEntries( - states.map((state, index) => [ - state, - getComputedCSSVariableValue(stateColorTokens[index] ?? "oklch(0.5 0 0)"), - ]), - ); + const rowSegments = buildGanttRowSegments(flatNodes, ganttDataItems); - const chartData = { - datasets: [ - { - backgroundColor: data.map((dataItem) => stateColorMap[dataItem.state ?? "none"]), - data: Boolean(selectedRun) ? data : [], - maxBarThickness: CHART_ROW_HEIGHT, - minBarLength: MIN_BAR_WIDTH, - }, - ], - labels, - }; - - const fixedHeight = flatNodes.length * CHART_ROW_HEIGHT + CHART_PADDING; - const selectedId = selectedTaskId ?? selectedGroupId; - - const handleBarClick = createHandleBarClick({ dagId, data, location, navigate, runId }); - - const handleBarHover = createHandleBarHover(data, setHoveredTaskId); - - const chartOptions = createChartOptions({ - data, - gridColor, - handleBarClick, - handleBarHover, - hoveredId: hoveredTaskId, - hoveredItemColor, - labels, - selectedId, - selectedItemColor, + const { maxMs, minMs } = computeGanttTimeRangeMs({ + data: ganttDataItems, selectedRun, selectedTimezone, - translate, }); + const virtualizerScrollPaddingStart = usesSharedScroll + ? GANTT_ROW_OFFSET_PX + : GANTT_STANDALONE_VIRTUALIZER_PADDING_START_PX; + if (runId === "") { return undefined; } - const handleChartMouseLeave = () => { + const handleStandaloneMouseLeave = () => { setHoveredTaskId(undefined); - - // Clear all hover styles when mouse leaves the chart area - const allTasks = document.querySelectorAll('[id*="-"]'); - - allTasks.forEach((task) => { - task.style.backgroundColor = ""; - }); }; - return ( - - setMode(NavigationModes.TI)} + rowSegments={rowSegments} + runId={runId} + scrollContainerRef={scrollContainerRef} + virtualizerScrollPaddingStart={virtualizerScrollPaddingStart} /> - + ) : undefined; + + if (usesSharedScroll) { + return ( + + {timeline} + + ); + } + + return ( + + + {timeline} + + ); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx new file mode 100644 index 0000000000000..75ad0a1b849f2 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx @@ -0,0 +1,359 @@ +/*! + * 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. + */ + +/* eslint-disable max-lines -- virtualized Gantt body markup is kept in one file for readability */ +import { Badge, Box, Flex, Text } from "@chakra-ui/react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import type { RefObject } from "react"; +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import { Link, useLocation, useParams } from "react-router-dom"; + +import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen"; +import { StateIcon } from "src/components/StateIcon"; +import TaskInstanceTooltip from "src/components/TaskInstanceTooltip"; +import { useHover } from "src/context/hover"; +import { + GANTT_AXIS_HEIGHT_PX, + GANTT_TOP_PADDING_PX, + ROW_HEIGHT, + TASK_BAR_HEIGHT_PX, +} from "src/layouts/Details/Grid/constants"; +import type { GridTask } from "src/layouts/Details/Grid/utils"; + +import { + type GanttDataItem, + GANTT_TIME_AXIS_TICK_COUNT, + buildGanttTimeAxisTicks, + getGanttSegmentTo, + gridSummariesToTaskIdMap, +} from "./utils"; + +const MIN_BAR_WIDTH_PX = 10; + +/** Short mark above the axis bottom border, aligned with each timestamp. */ +const GANTT_AXIS_TICK_HEIGHT_PX = 6; + +type Props = { + readonly dagId: string; + readonly flatNodes: Array; + readonly ganttDataItems: Array; + readonly gridSummaries: Array; + readonly maxMs: number; + readonly minMs: number; + readonly onSegmentClick?: () => void; + readonly rowSegments: Array>; + readonly runId: string; + readonly scrollContainerRef: RefObject; + /** scrollPaddingStart for @tanstack/react-virtual (116 standalone, 180 with shared outer padding). */ + readonly virtualizerScrollPaddingStart: number; +}; + +const toTooltipSummary = ( + segment: GanttDataItem, + node: GridTask, + gridSummary: LightGridTaskInstanceSummary | undefined, +): LightGridTaskInstanceSummary => { + if (gridSummary !== undefined && (node.isGroup ?? node.is_mapped)) { + return gridSummary; + } + + return { + child_states: null, + max_end_date: new Date(segment.x[1]).toISOString(), + min_start_date: new Date(segment.x[0]).toISOString(), + state: segment.state ?? null, + task_display_name: segment.y, + task_id: segment.taskId, + }; +}; + +export const GanttTimeline = ({ + dagId, + flatNodes, + ganttDataItems, + gridSummaries, + maxMs, + minMs, + onSegmentClick, + rowSegments, + runId, + scrollContainerRef, + virtualizerScrollPaddingStart, +}: Props) => { + const location = useLocation(); + const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams(); + const { hoveredTaskId, setHoveredTaskId } = useHover(); + const [bodyWidthPx, setBodyWidthPx] = useState(0); + const bodyRef = useRef(null); + + useLayoutEffect(() => { + const el = bodyRef.current; + + if (el === null) { + return undefined; + } + + const ro = new ResizeObserver((entries) => { + const nextWidth = entries[0]?.contentRect.width; + + if (nextWidth !== undefined) { + setBodyWidthPx(nextWidth); + } + }); + + ro.observe(el); + + return () => { + ro.disconnect(); + }; + }, []); + + const summaryByTaskId = gridSummariesToTaskIdMap(gridSummaries); + const spanMs = Math.max(1, maxMs - minMs); + + // Derive tick count from available width so labels never overlap. + // Each "HH:MM:SS" label is ~8 chars at font-size xs; allow ~80px per tick. + const MIN_TICK_SPACING_PX = 80; + const tickCount = + bodyWidthPx > 0 ? Math.max(2, Math.floor(bodyWidthPx / MIN_TICK_SPACING_PX)) : GANTT_TIME_AXIS_TICK_COUNT; + const timeTicks = buildGanttTimeAxisTicks(minMs, maxMs, tickCount); + + const rowVirtualizer = useVirtualizer({ + count: flatNodes.length, + estimateSize: () => ROW_HEIGHT, + // @tanstack/react-virtual: scroll container ref; the hook subscribes to this element's scroll/resize. + getScrollElement: () => scrollContainerRef.current, + overscan: 5, + scrollPaddingStart: virtualizerScrollPaddingStart, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + const segmentLayout = (segment: GanttDataItem) => { + const leftPct = ((segment.x[0] - minMs) / spanMs) * 100; + const widthPct = ((segment.x[1] - segment.x[0]) / spanMs) * 100; + const widthPx = (widthPct / 100) * bodyWidthPx; + const minBoost = widthPx < MIN_BAR_WIDTH_PX && bodyWidthPx > 0 ? MIN_BAR_WIDTH_PX - widthPx : 0; + const widthPctAdjusted = bodyWidthPx > 0 ? ((widthPx + minBoost) / bodyWidthPx) * 100 : widthPct; + + return { + leftPct, + widthPct: Math.min(widthPctAdjusted, 100 - leftPct), + }; + }; + + return ( + + + + + {timeTicks.map(({ label, labelAlign, leftPct }) => ( + + + + {label} + + + ))} + + + + + + + {timeTicks.map(({ leftPct }) => ( + + ))} + + {virtualItems.map((vItem) => { + const node = flatNodes[vItem.index]; + + if (node === undefined) { + return undefined; + } + + const segments = rowSegments[vItem.index] ?? []; + const taskId = node.id; + const isSelected = selectedTaskId === taskId || selectedGroupId === taskId; + const isHovered = hoveredTaskId === taskId; + const gridSummary = summaryByTaskId.get(taskId); + + return ( + + setHoveredTaskId(taskId)} + onMouseLeave={() => setHoveredTaskId(undefined)} + overflow="hidden" + position="relative" + px="3px" + transition="background-color 0.2s" + w="100%" + > + {segments.map((segment, segIndex) => { + const { leftPct, widthPct } = segmentLayout(segment); + const to = getGanttSegmentTo({ + dagId, + data: ganttDataItems, + item: segment, + location, + runId, + }); + const tooltipInstance = toTooltipSummary(segment, node, gridSummary); + + if (to === undefined) { + return undefined; + } + + return ( + + + onSegmentClick?.()} + replace + style={{ display: "block", height: "100%", width: "100%" }} + to={to} + > + + + + + + + ); + })} + + + ); + })} + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts new file mode 100644 index 0000000000000..53afd708bf68a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts @@ -0,0 +1,90 @@ +/*! + * 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 { describe, expect, it } from "vitest"; + +import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen"; +import type { GridTask } from "src/layouts/Details/Grid/utils"; + +import { + type GanttDataItem, + buildGanttRowSegments, + buildGanttTimeAxisTicks, + GANTT_TIME_AXIS_TICK_COUNT, + gridSummariesToTaskIdMap, +} from "./utils"; + +describe("buildGanttTimeAxisTicks", () => { + it("returns evenly spaced elapsed labels with edge alignment", () => { + const minMs = 0; + const maxMs = 60_000; + const ticks = buildGanttTimeAxisTicks(minMs, maxMs); + + expect(ticks).toHaveLength(GANTT_TIME_AXIS_TICK_COUNT); + expect(ticks[0]?.leftPct).toBe(0); + expect(ticks[0]?.label).toBe("00:00:00"); + expect(ticks[0]?.labelAlign).toBe("left"); + expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.leftPct).toBe(100); + expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.labelAlign).toBe("right"); + expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.label).toBe("00:01:00"); + expect(ticks[1]?.labelAlign).toBe("center"); + expect(ticks.every((tick) => typeof tick.label === "string" && tick.label.length > 0)).toBe(true); + }); + + it("supports a single tick", () => { + const ticks = buildGanttTimeAxisTicks(1000, 1000, 1); + + expect(ticks).toHaveLength(1); + expect(ticks[0]?.leftPct).toBe(0); + expect(ticks[0]?.labelAlign).toBe("left"); + expect(ticks[0]?.label).toBe("00:00:00"); + }); +}); + +describe("gridSummariesToTaskIdMap", () => { + it("indexes summaries by task_id", () => { + const summaries = [ + { state: null, task_id: "a" } as LightGridTaskInstanceSummary, + { state: null, task_id: "b" } as LightGridTaskInstanceSummary, + ]; + const map = gridSummariesToTaskIdMap(summaries); + + expect(map.get("a")).toBe(summaries[0]); + expect(map.get("b")).toBe(summaries[1]); + expect(map.size).toBe(2); + }); +}); + +describe("buildGanttRowSegments", () => { + it("groups items by task id in flat node order", () => { + const flatNodes: Array = [ + { depth: 0, id: "t1", is_mapped: false, label: "a" } as GridTask, + { depth: 0, id: "t2", is_mapped: false, label: "b" } as GridTask, + ]; + const items: Array = [ + { taskId: "t2", x: [1_577_836_800_000, 1_577_923_200_000], y: "b" }, + { taskId: "t1", x: [1_577_836_800_000, 1_577_923_200_000], y: "a" }, + ]; + + const segments = buildGanttRowSegments(flatNodes, items); + + expect(segments).toHaveLength(2); + expect(segments[0]?.map((segment) => segment.taskId)).toEqual(["t1"]); + expect(segments[1]?.map((segment) => segment.taskId)).toEqual(["t2"]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts index b2a54fa7d7e82..fe802cfa25f46 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines */ - /*! * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -18,10 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import type { ChartEvent, ActiveElement, TooltipItem } from "chart.js"; import dayjs from "dayjs"; -import type { TFunction } from "i18next"; -import type { NavigateFunction, Location } from "react-router-dom"; +import type { Location, To } from "react-router-dom"; import type { GanttTaskInstance, @@ -31,8 +27,8 @@ import type { } from "openapi/requests"; import { SearchParamsKeys } from "src/constants/searchParams"; import type { GridTask } from "src/layouts/Details/Grid/utils"; -import { getDuration, isStatePending } from "src/utils"; -import { formatDate } from "src/utils/datetimeUtils"; +import { isStatePending } from "src/utils"; +import { formatDate, renderDuration } from "src/utils/datetimeUtils"; import { buildTaskInstanceUrl } from "src/utils/links"; export type GanttDataItem = { @@ -41,45 +37,43 @@ export type GanttDataItem = { state?: TaskInstanceState | null; taskId: string; tryNumber?: number; - x: Array; + /** [startMs, endMs] as Unix millisecond timestamps — pre-parsed to avoid repeated `new Date()` in render loops. */ + x: [number, number]; y: string; }; -type HandleBarClickOptions = { +type GanttSegmentLinkParams = { dagId: string; data: Array; + item: GanttDataItem; location: Location; - navigate: NavigateFunction; runId: string; }; -type ChartOptionsParams = { - data: Array; - gridColor?: string; - handleBarClick: (event: ChartEvent, elements: Array) => void; - handleBarHover: (event: ChartEvent, elements: Array) => void; - hoveredId?: string | null; - hoveredItemColor?: string; - labels: Array; - selectedId?: string; - selectedItemColor?: string; - selectedRun?: GridRunsResponse; - selectedTimezone: string; - translate: TFunction; -}; - type TransformGanttDataParams = { allTries: Array; flatNodes: Array; gridSummaries: Array; }; +export const gridSummariesToTaskIdMap = ( + summaries: Array, +): Map => { + const byId = new Map(); + + for (const summary of summaries) { + byId.set(summary.task_id, summary); + } + + return byId; +}; + export const transformGanttData = ({ allTries, flatNodes, gridSummaries, }: TransformGanttDataParams): Array => { - // Group tries by task_id + // Pre-index both lookups as Maps to keep the overall transform O(n+m). const triesByTask = new Map>(); for (const ti of allTries) { @@ -89,12 +83,13 @@ export const transformGanttData = ({ triesByTask.set(ti.task_id, existing); } + const summaryByTaskId = gridSummariesToTaskIdMap(gridSummaries); + return flatNodes .flatMap((node): Array | undefined => { - const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id); + const gridSummary = summaryByTaskId.get(node.id); - // Handle groups and mapped tasks using grid summary (aggregated min/max times) - // Use ISO so time scale and bar positions render consistently across browsers + // Groups and mapped tasks show a single aggregate bar sourced from grid summaries. if ((node.isGroup ?? node.is_mapped) && gridSummary) { if (gridSummary.min_start_date === null || gridSummary.max_end_date === null) { return undefined; @@ -106,16 +101,12 @@ export const transformGanttData = ({ isMapped: node.is_mapped, state: gridSummary.state, taskId: gridSummary.task_id, - x: [ - dayjs(gridSummary.min_start_date).toISOString(), - dayjs(gridSummary.max_end_date).toISOString(), - ], + x: [dayjs(gridSummary.min_start_date).valueOf(), dayjs(gridSummary.max_end_date).valueOf()], y: gridSummary.task_id, }, ]; } - // Handle individual tasks with all their tries if (!node.isGroup) { const tries = triesByTask.get(node.id); @@ -123,8 +114,8 @@ export const transformGanttData = ({ return tries .filter((tryInstance) => tryInstance.start_date !== null) .map((tryInstance) => { - const hasTaskRunning = isStatePending(tryInstance.state); - const endTime = hasTaskRunning ? dayjs().toISOString() : tryInstance.end_date; + const endMs = + tryInstance.end_date === null ? Date.now() : dayjs(tryInstance.end_date).valueOf(); return { isGroup: false, @@ -132,7 +123,7 @@ export const transformGanttData = ({ state: tryInstance.state, taskId: tryInstance.task_id, tryNumber: tryInstance.try_number, - x: [dayjs(tryInstance.start_date).toISOString(), dayjs(endTime).toISOString()], + x: [dayjs(tryInstance.start_date).valueOf(), endMs], y: tryInstance.task_display_name, }; }); @@ -144,246 +135,138 @@ export const transformGanttData = ({ .filter((item): item is GanttDataItem => item !== undefined); }; -export const createHandleBarClick = - ({ dagId, data, location, navigate, runId }: HandleBarClickOptions) => - (_: ChartEvent, elements: Array) => { - if (elements.length === 0 || !elements[0] || !runId) { - return; - } - - const clickedData = data[elements[0].index]; +/** One entry per flat node: segments to draw in that row (tries or aggregate). */ +export const buildGanttRowSegments = ( + flatNodes: Array, + items: Array, +): Array> => { + const byTaskId = new Map>(); - if (!clickedData) { - return; - } + for (const item of items) { + const list = byTaskId.get(item.taskId) ?? []; - const { isGroup, isMapped, taskId, tryNumber } = clickedData; - - const taskUrl = buildTaskInstanceUrl({ - currentPathname: location.pathname, - dagId, - isGroup: Boolean(isGroup), - isMapped: Boolean(isMapped), - runId, - taskId, - }); - - const searchParams = new URLSearchParams(location.search); - const isOlderTry = - tryNumber !== undefined && - tryNumber < - Math.max(...data.filter((item) => item.taskId === taskId).map((item) => item.tryNumber ?? 1)); - - if (isOlderTry) { - searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString()); - } else { - searchParams.delete(SearchParamsKeys.TRY_NUMBER); - } - - void Promise.resolve( - navigate( - { - pathname: taskUrl, - search: searchParams.toString(), - }, - { replace: true }, - ), - ); - }; + list.push(item); + byTaskId.set(item.taskId, list); + } -export const createHandleBarHover = ( - data: Array, - setHoveredTaskId: (taskId: string | undefined) => void, -) => { - let lastHoveredTaskId: string | undefined = undefined; - - return (_: ChartEvent, elements: Array) => { - // Clear previous hover styles - if (lastHoveredTaskId !== undefined) { - const previousTasks = document.querySelectorAll( - `#${lastHoveredTaskId.replaceAll(".", "-")}`, - ); - - previousTasks.forEach((task) => { - task.style.backgroundColor = ""; - }); - } - - if (elements.length > 0 && elements[0] && elements[0].index < data.length) { - const hoveredData = data[elements[0].index]; - - if (hoveredData?.taskId !== undefined) { - lastHoveredTaskId = hoveredData.taskId; - setHoveredTaskId(hoveredData.taskId); - - // Apply new hover styles - const tasks = document.querySelectorAll( - `#${hoveredData.taskId.replaceAll(".", "-")}`, - ); - - tasks.forEach((task) => { - task.style.backgroundColor = "var(--chakra-colors-info-subtle)"; - }); - } - } else { - lastHoveredTaskId = undefined; - setHoveredTaskId(undefined); - } - }; + return flatNodes.map((node) => byTaskId.get(node.id) ?? []); }; -export const createChartOptions = ({ +export const computeGanttTimeRangeMs = ({ data, - gridColor, - handleBarClick, - handleBarHover, - hoveredId, - hoveredItemColor, - labels, - selectedId, - selectedItemColor, selectedRun, selectedTimezone, - translate, -}: ChartOptionsParams) => { - const isActivePending = isStatePending(selectedRun?.state); +}: { + data: Array; + selectedRun?: GridRunsResponse; + selectedTimezone: string; +}): { maxMs: number; minMs: number } => { + const isActivePending = selectedRun !== undefined && isStatePending(selectedRun.state); const effectiveEndDate = isActivePending ? dayjs().tz(selectedTimezone).format("YYYY-MM-DD HH:mm:ss") : selectedRun?.end_date; + if (data.length === 0) { + const minMs = new Date(formatDate(selectedRun?.start_date, selectedTimezone)).getTime(); + const maxMs = new Date(formatDate(effectiveEndDate, selectedTimezone)).getTime(); + + return { maxMs, minMs }; + } + + const maxTime = data.reduce((max, item) => Math.max(max, item.x[1]), -Infinity); + const minTime = data.reduce((min, item) => Math.min(min, item.x[0]), Infinity); + const totalDuration = maxTime - minTime; + return { - animation: { - duration: 150, - easing: "linear" as const, - }, - indexAxis: "y" as const, - maintainAspectRatio: false, - onClick: handleBarClick, - onHover: (event: ChartEvent, elements: Array) => { - const target = event.native?.target as HTMLElement | undefined; - - if (target) { - target.style.cursor = elements.length > 0 ? "pointer" : "default"; - } + maxMs: maxTime + totalDuration * 0.05, + minMs: minTime - totalDuration * 0.02, + }; +}; - handleBarHover(event, elements); - }, - plugins: { - annotation: { - annotations: [ - // Selected task annotation - ...(selectedId === undefined || selectedId === "" || hoveredId === selectedId - ? [] - : [ - { - backgroundColor: selectedItemColor, - borderWidth: 0, - drawTime: "beforeDatasetsDraw" as const, - type: "box" as const, - xMax: "max" as const, - xMin: "min" as const, - yMax: labels.indexOf(selectedId) + 0.5, - yMin: labels.indexOf(selectedId) - 0.5, - }, - ]), - // Hovered task annotation - ...(hoveredId === null || hoveredId === undefined - ? [] - : [ - { - backgroundColor: hoveredItemColor, - borderWidth: 0, - drawTime: "beforeDatasetsDraw" as const, - type: "box" as const, - xMax: "max" as const, - xMin: "min" as const, - yMax: labels.indexOf(hoveredId) + 0.5, - yMin: labels.indexOf(hoveredId) - 0.5, - }, - ]), - ], - clip: false, - }, - legend: { - display: false, - }, - tooltip: { - callbacks: { - afterBody(tooltipItems: Array>) { - const taskInstance = data[tooltipItems[0]?.dataIndex ?? 0]; - const startDate = formatDate(taskInstance?.x[0], selectedTimezone); - const endDate = formatDate(taskInstance?.x[1], selectedTimezone); - const lines = [ - `${translate("startDate")}: ${startDate}`, - `${translate("endDate")}: ${endDate}`, - `${translate("duration")}: ${getDuration(taskInstance?.x[0], taskInstance?.x[1])}`, - ]; - - if (taskInstance?.tryNumber !== undefined) { - lines.unshift(`${translate("tryNumber")}: ${taskInstance.tryNumber}`); - } - - return lines; - }, - label(tooltipItem: TooltipItem<"bar">) { - const taskInstance = data[tooltipItem.dataIndex]; +export const getGanttSegmentTo = ({ + dagId, + data, + item, + location, + runId, +}: GanttSegmentLinkParams): To | undefined => { + if (!runId) { + return undefined; + } - return `${translate("state")}: ${translate(`states.${taskInstance?.state}`)}`; - }, - }, - }, - }, - resizeDelay: 100, - responsive: true, - scales: { - x: { - grid: { - color: gridColor, - display: true, - }, - max: - data.length > 0 - ? (() => { - const maxTime = Math.max(...data.map((item) => new Date(item.x[1] ?? "").getTime())); - const minTime = Math.min(...data.map((item) => new Date(item.x[0] ?? "").getTime())); - const totalDuration = maxTime - minTime; - - // add 5% to the max time to avoid the last tick being cut off - return maxTime + totalDuration * 0.05; - })() - : formatDate(effectiveEndDate, selectedTimezone), - min: - data.length > 0 - ? (() => { - const maxTime = Math.max(...data.map((item) => new Date(item.x[1] ?? "").getTime())); - const minTime = Math.min(...data.map((item) => new Date(item.x[0] ?? "").getTime())); - const totalDuration = maxTime - minTime; - - // subtract 2% from min time so background color shows before data - return minTime - totalDuration * 0.02; - })() - : formatDate(selectedRun?.start_date, selectedTimezone), - position: "top" as const, - stacked: true, - ticks: { - align: "start" as const, - callback: (value: number | string) => formatDate(value, selectedTimezone, "HH:mm:ss"), - maxRotation: 8, - maxTicksLimit: 8, - minRotation: 8, - }, - type: "time" as const, - }, - y: { - grid: { - color: gridColor, - display: true, - }, - stacked: true, - ticks: { - display: false, - }, - }, - }, + const { isGroup, isMapped, taskId, tryNumber } = item; + + const pathname = buildTaskInstanceUrl({ + currentPathname: location.pathname, + dagId, + isGroup: Boolean(isGroup), + isMapped: Boolean(isMapped), + runId, + taskId, + }); + + const searchParams = new URLSearchParams(location.search); + const tryNumbersForTask = data + .filter((row: GanttDataItem) => row.taskId === taskId) + .map((row: GanttDataItem) => row.tryNumber ?? 1); + const maxTryForTask = tryNumbersForTask.length > 0 ? Math.max(...tryNumbersForTask) : 1; + const isOlderTry = tryNumber !== undefined && tryNumber < maxTryForTask; + + if (isOlderTry) { + searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString()); + } else { + searchParams.delete(SearchParamsKeys.TRY_NUMBER); + } + + return { + pathname, + search: searchParams.toString(), }; }; + +/** Default number of time labels along the Gantt axis (endpoints included). */ +export const GANTT_TIME_AXIS_TICK_COUNT = 8; + +export type GanttAxisTickLabelAlign = "center" | "left" | "right"; + +export type GanttAxisTick = { + label: string; + labelAlign: GanttAxisTickLabelAlign; + leftPct: number; +}; + +/** Elapsed time from the chart origin (`minMs`), formatted like grid duration labels (no wall-clock). */ +const formatElapsedMsForGanttAxis = (elapsedMs: number): string => { + const seconds = Math.max(0, elapsedMs / 1000); + + if (seconds <= 0.01) { + return "00:00:00"; + } + + return renderDuration(seconds, false) ?? "00:00:00"; +}; + +export const buildGanttTimeAxisTicks = ( + minMs: number, + maxMs: number, + tickCount: number = GANTT_TIME_AXIS_TICK_COUNT, +): Array => { + const spanMs = Math.max(1, maxMs - minMs); + const denominator = Math.max(1, tickCount - 1); + const lastIndex = tickCount - 1; + const ticks: Array = []; + + for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + const elapsedMs = (tickIndex / denominator) * spanMs; + const labelAlign: GanttAxisTickLabelAlign = + tickCount === 1 ? "left" : tickIndex === 0 ? "left" : tickIndex === lastIndex ? "right" : "center"; + + ticks.push({ + label: formatElapsedMsForGanttAxis(elapsedMs), + labelAlign, + leftPct: (tickIndex / denominator) * 100, + }); + } + + return ticks; +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx index 0c798ab843a9e..17ec5cdb0a07e 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx @@ -19,5 +19,13 @@ import { Box, type BoxProps } from "@chakra-ui/react"; export const DurationAxis = (props: BoxProps) => ( - + ); 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 dbe30598010ad..9052144f470cc 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 @@ -20,7 +20,8 @@ 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 { useEffect, useRef, useState } from "react"; +import type { RefObject } from "react"; import { useTranslation } from "react-i18next"; import { FiChevronsRight } from "react-icons/fi"; import { Link, useParams, useSearchParams } from "react-router-dom"; @@ -39,14 +40,9 @@ import { DurationAxis } from "./DurationAxis"; import { DurationTick } from "./DurationTick"; import { TaskInstancesColumn } from "./TaskInstancesColumn"; import { TaskNames } from "./TaskNames"; -import { - GRID_HEADER_HEIGHT_PX, - GRID_HEADER_PADDING_PX, - GRID_OUTER_PADDING_PX, - ROW_HEIGHT, -} from "./constants"; +import { GANTT_ROW_OFFSET_PX, GRID_HEADER_HEIGHT_PX, GRID_HEADER_PADDING_PX, ROW_HEIGHT } from "./constants"; import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags"; -import { flattenNodes } from "./utils"; +import { estimateTaskNameColumnWidthPx, flattenNodes } from "./utils"; dayjs.extend(dayjsDuration); @@ -56,24 +52,30 @@ type Props = { readonly runAfterGte?: string; readonly runAfterLte?: string; readonly runType?: DagRunType | undefined; + readonly sharedScrollContainerRef?: RefObject; readonly showGantt?: boolean; readonly showVersionIndicatorMode?: VersionIndicatorOptions; readonly triggeringUser?: string | undefined; }; +const GRID_INNER_SCROLL_PADDING_START_PX = GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX; + export const Grid = ({ dagRunState, limit, runAfterGte, runAfterLte, runType, + sharedScrollContainerRef, showGantt, showVersionIndicatorMode, triggeringUser, }: Props) => { const { t: translate } = useTranslation("dag"); const gridRef = useRef(null); - const scrollContainerRef = useRef(null); + const scrollContainerRef = useRef(null); + + const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt); const [selectedIsVisible, setSelectedIsVisible] = useState(); const { openGroupIds, toggleGroupId } = useOpenGroups(); @@ -141,7 +143,24 @@ export const Grid = ({ showVersionIndicatorMode, }); - const { flatNodes } = useMemo(() => flattenNodes(dagStructure, openGroupIds), [dagStructure, openGroupIds]); + const { flatNodes } = flattenNodes(dagStructure, openGroupIds); + + const taskNameColumnWidthPx = showGantt ? estimateTaskNameColumnWidthPx(flatNodes) : undefined; + + const taskNameColumnStyles = + showGantt && taskNameColumnWidthPx !== undefined + ? { + flexGrow: 0, + flexShrink: 0, + maxW: `${taskNameColumnWidthPx}px`, + minW: `${taskNameColumnWidthPx}px`, + width: `${taskNameColumnWidthPx}px`, + } + : { + flexGrow: 1, + flexShrink: 0, + minW: "200px", + }; const { setMode } = useNavigation({ onToggleGroup: toggleGroupId, @@ -156,100 +175,115 @@ export const Grid = ({ const rowVirtualizer = useVirtualizer({ count: flatNodes.length, estimateSize: () => ROW_HEIGHT, - getScrollElement: () => scrollContainerRef.current, + // @tanstack/react-virtual: pass element resolver inline; hook tracks scroll container via its own subscriptions. + getScrollElement: () => + usesSharedScroll ? (sharedScrollContainerRef?.current ?? null) : scrollContainerRef.current, overscan: 5, + scrollPaddingStart: usesSharedScroll ? GANTT_ROW_OFFSET_PX : GRID_INNER_SCROLL_PADDING_START_PX, }); const virtualItems = rowVirtualizer.getVirtualItems(); + const gridHeaderAndBody = ( + <> + {/* Grid header, both bgs are needed to hide elements during horizontal and vertical scroll */} + + + + {Boolean(gridRuns?.length) && ( + <> + + + + )} + + + {/* Duration bars */} + + + + + + + {runsWithVersionFlags?.map((dr) => ( + + ))} + + {selectedIsVisible === undefined || !selectedIsVisible ? undefined : ( + + + + + + )} + + + + + {/* Grid body */} + + + + + + {gridRuns?.map((dr: GridRunsResponse) => ( + + ))} + + + + ); + return ( - {/* Grid scroll container */} - - {/* Grid header, both bgs are needed to hide elements during horizontal and vertical scroll */} - - - - {Boolean(gridRuns?.length) && ( - <> - - - - )} - - - {/* Duration bars */} - - - - - - - {runsWithVersionFlags?.map((dr) => ( - - ))} - - {selectedIsVisible === undefined || !selectedIsVisible ? undefined : ( - - - - - - )} - - - - - {/* Grid body */} - - - - - - {gridRuns?.map((dr: GridRunsResponse) => ( - - ))} - - - + {usesSharedScroll ? ( + gridHeaderAndBody + ) : ( + + {gridHeaderAndBody} + + )} ); }; 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 05b321455143c..ed47572760cdf 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 @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Box } from "@chakra-ui/react"; +import { Box, type BoxProps } from "@chakra-ui/react"; import type { VirtualItem } from "@tanstack/react-virtual"; import { useParams } from "react-router-dom"; @@ -27,6 +27,7 @@ import { useHover } from "src/context/hover"; import { GridTI } from "./GridTI"; import { DagVersionIndicator } from "./VersionIndicator"; +import { ROW_HEIGHT } from "./constants"; import type { GridTask } from "./utils"; type Props = { @@ -38,7 +39,16 @@ type Props = { readonly virtualItems?: Array; }; -const ROW_HEIGHT = 20; +type CellBorderProps = Pick; + +const taskInstanceCellBorderProps = (hideRowBorders: boolean, rowIndex: number): CellBorderProps => + hideRowBorders + ? { borderBottomWidth: 0, borderTopWidth: 0 } + : { + borderBottomWidth: 1, + borderColor: "border", + borderTopWidth: rowIndex === 0 ? 1 : 0, + }; export const TaskInstancesColumn = ({ nodes, @@ -69,6 +79,7 @@ export const TaskInstancesColumn = ({ const hasMixedVersions = versionNumbers.size > 1; const isHovered = hoveredRunId === run.run_id; + const hideRowBorders = isSelected || isHovered; const handleMouseEnter = () => setHoveredRunId(run.run_id); const handleMouseLeave = () => setHoveredRunId(undefined); @@ -94,6 +105,7 @@ export const TaskInstancesColumn = ({ if (!taskInstance) { return ( ; }; -const ROW_HEIGHT = 20; - const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`; export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => { diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts index 99bd035896de0..616dd2223d905 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts @@ -19,17 +19,20 @@ // Grid layout constants - shared between Grid and Gantt for alignment export const ROW_HEIGHT = 20; -export const GRID_OUTER_PADDING_PX = 64; // pt={16} = 16 * 4 = 64px +/** Height of a task bar / badge within a row — matches the GridTI badge height. */ +export const TASK_BAR_HEIGHT_PX = 14; export const GRID_HEADER_PADDING_PX = 16; // pt={4} = 4 * 4 = 16px export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars // Gantt chart's x-axis height (time labels at top of chart) export const GANTT_AXIS_HEIGHT_PX = 36; -// Total offset from top of Grid component to where task rows begin, -// minus the Gantt axis height since the chart includes its own top axis -export const GRID_BODY_OFFSET_PX = - GRID_OUTER_PADDING_PX + GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX - GANTT_AXIS_HEIGHT_PX; +// Padding at top of Gantt scroll content so task rows share scrollTop with Grid +// (grid scroll begins with sticky header; Gantt begins with the time axis). +export const GANTT_TOP_PADDING_PX = GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX - GANTT_AXIS_HEIGHT_PX; + +/** Offset from scroll top to the first task row — used to align Grid and Gantt virtualizers. */ +export const GANTT_ROW_OFFSET_PX = GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX; // Version indicator constants export const BAR_HEIGHT = GRID_HEADER_HEIGHT_PX; // Duration bar height matches grid header diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts index e60012e58ba80..b4b3d923e334a 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts @@ -16,8 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { useMemo } from "react"; - import type { GridRunsResponse } from "openapi/requests"; import { VersionIndicatorOptions } from "src/constants/showVersionIndicatorOptions"; @@ -44,31 +42,29 @@ export const useGridRunsWithVersionFlags = ({ }: UseGridRunsWithVersionFlagsParams): Array | undefined => { const isVersionIndicatorEnabled = showVersionIndicatorMode !== VersionIndicatorOptions.NONE; - return useMemo(() => { - if (!gridRuns) { - return undefined; - } + if (!gridRuns) { + return undefined; + } - if (!isVersionIndicatorEnabled) { - return gridRuns.map((run) => ({ ...run, isBundleVersionChange: false, isDagVersionChange: false })); - } + if (!isVersionIndicatorEnabled) { + return gridRuns.map((run) => ({ ...run, isBundleVersionChange: false, isDagVersionChange: false })); + } - return gridRuns.map((run, index) => { - const nextRun = gridRuns[index + 1]; + return gridRuns.map((run, index) => { + const nextRun = gridRuns[index + 1]; - const currentBundleVersion = getBundleVersion(run); - const nextBundleVersion = nextRun ? getBundleVersion(nextRun) : undefined; - const isBundleVersionChange = - currentBundleVersion !== undefined && - nextBundleVersion !== undefined && - currentBundleVersion !== nextBundleVersion; + const currentBundleVersion = getBundleVersion(run); + const nextBundleVersion = nextRun ? getBundleVersion(nextRun) : undefined; + const isBundleVersionChange = + currentBundleVersion !== undefined && + nextBundleVersion !== undefined && + currentBundleVersion !== nextBundleVersion; - const currentVersion = getMaxVersionNumber(run); - const nextVersion = nextRun ? getMaxVersionNumber(nextRun) : undefined; - const isDagVersionChange = - currentVersion !== undefined && nextVersion !== undefined && currentVersion !== nextVersion; + const currentVersion = getMaxVersionNumber(run); + const nextVersion = nextRun ? getMaxVersionNumber(nextRun) : undefined; + const isDagVersionChange = + currentVersion !== undefined && nextVersion !== undefined && currentVersion !== nextVersion; - return { ...run, isBundleVersionChange, isDagVersionChange }; - }); - }, [gridRuns, isVersionIndicatorEnabled]); + return { ...run, isBundleVersionChange, isDagVersionChange }; + }); }; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts new file mode 100644 index 0000000000000..b034a8f3100e5 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts @@ -0,0 +1,59 @@ +/*! + * 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 { describe, expect, it } from "vitest"; + +import { type GridTask, estimateTaskNameColumnWidthPx } from "./utils"; + +const baseNode = { + id: "t1", + is_mapped: false, + label: "task_a", +} as const; + +describe("estimateTaskNameColumnWidthPx", () => { + it("returns the layout minimum when there are no nodes", () => { + expect(estimateTaskNameColumnWidthPx([])).toBe(200); + }); + + it("returns at least the layout minimum for a short label", () => { + const nodes: Array = [{ ...baseNode, depth: 0, label: "a" } as GridTask]; + + expect(estimateTaskNameColumnWidthPx(nodes)).toBe(200); + }); + + it("increases width for longer labels, depth, group chevron, and mapped hint", () => { + const plain: Array = [{ ...baseNode, depth: 0, id: "a", label: "x".repeat(40) } as GridTask]; + const deep: Array = [{ ...baseNode, depth: 4, id: "b", label: "x".repeat(40) } as GridTask]; + const group: Array = [ + { ...baseNode, depth: 0, id: "c", isGroup: true, label: "x".repeat(40) } as GridTask, + ]; + const mapped: Array = [ + { ...baseNode, depth: 0, id: "d", is_mapped: true, label: "x".repeat(40) } as GridTask, + ]; + + const wPlain = estimateTaskNameColumnWidthPx(plain); + const wDeep = estimateTaskNameColumnWidthPx(deep); + const wGroup = estimateTaskNameColumnWidthPx(group); + const wMapped = estimateTaskNameColumnWidthPx(mapped); + + expect(wDeep).toBeGreaterThan(wPlain); + expect(wGroup).toBeGreaterThan(wPlain); + expect(wMapped).toBeGreaterThan(wPlain); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts index 019039a84011a..242004a91200e 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts +++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts @@ -24,6 +24,41 @@ export type GridTask = { isOpen?: boolean; } & GridNodeResponse; +/** Minimum width for the task-name column (matches prior `minWidth="200px"`). */ +const TASK_NAME_COLUMN_MIN_WIDTH_PX = 200; + +/** + * Chakra `rem` is typically 16px. Must match TaskNames `indent(depth)`: + * `(depth * 0.75 + 0.5)rem`. + */ +const ROOT_FONT_SIZE_PX = 16; + +const indentRem = (depth: number) => depth * 0.75 + 0.5; + +/** + * Approximate rendered width for the task-name column when the Gantt is shown. + * Task rows use absolute positioning, so the parent needs an explicit width. + */ +export const estimateTaskNameColumnWidthPx = (nodes: Array): number => { + let max = TASK_NAME_COLUMN_MIN_WIDTH_PX; + + for (const node of nodes) { + const indentPx = indentRem(node.depth) * ROOT_FONT_SIZE_PX; + // TaskNames uses fontSize="sm" (~14px); average glyph width ~8px for mixed labels. + const labelChars = + node.label.length + + (Boolean(node.is_mapped) ? 6 : 0) + + (node.setup_teardown_type === "setup" || node.setup_teardown_type === "teardown" ? 4 : 0); + const textPx = labelChars * 8; + const groupChevronPx = node.isGroup ? 28 : 0; + const paddingPx = 16; + + max = Math.max(max, Math.ceil(indentPx + textPx + groupChevronPx + paddingPx)); + } + + return max; +}; + export const flattenNodes = ( nodes: Array | undefined, openGroupIds: Array, 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 860f9a4d200e3..d39ae388ca300 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx @@ -31,11 +31,11 @@ import { VStack, } from "@chakra-ui/react"; import { useReactFlow } from "@xyflow/react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { useTranslation } from "react-i18next"; import { FiGrid } from "react-icons/fi"; -import { LuKeyboard } from "react-icons/lu"; +import { LuChartGantt, LuKeyboard } from "react-icons/lu"; import { MdOutlineAccountTree, MdSettings } from "react-icons/md"; import type { ImperativePanelGroupHandle } from "react-resizable-panels"; import { useParams } from "react-router-dom"; @@ -52,7 +52,6 @@ import { SearchBar } from "src/components/SearchBar"; import { StateBadge } from "src/components/StateBadge"; import { Tooltip } from "src/components/ui"; import { type ButtonGroupOption, ButtonGroupToggle } from "src/components/ui/ButtonGroupToggle"; -import { Checkbox } from "src/components/ui/Checkbox"; import { dependenciesKey, directionKey } from "src/constants/localStorage"; import type { VersionIndicatorOptions } from "src/constants/showVersionIndicatorOptions"; import { dagRunTypeOptions, dagRunStateOptions } from "src/constants/stateOptions"; @@ -65,24 +64,24 @@ import { TaskStreamFilter } from "./TaskStreamFilter"; import { ToggleGroups } from "./ToggleGroups"; import { VersionIndicatorSelect } from "./VersionIndicatorSelect"; +type DagView = "gantt" | "graph" | "grid"; + type Props = { readonly dagRunStateFilter: DagRunState | undefined; - readonly dagView: "graph" | "grid"; + readonly dagView: DagView; readonly limit: number; readonly panelGroupRef: React.RefObject; readonly runAfterGte: string | undefined; readonly runAfterLte: string | undefined; readonly runTypeFilter: DagRunType | undefined; readonly setDagRunStateFilter: React.Dispatch>; - readonly setDagView: (x: "graph" | "grid") => void; + readonly setDagView: (view: DagView) => void; readonly setLimit: React.Dispatch>; readonly setRunAfterGte: React.Dispatch>; readonly setRunAfterLte: React.Dispatch>; readonly setRunTypeFilter: React.Dispatch>; - readonly setShowGantt: React.Dispatch>; readonly setShowVersionIndicatorMode: React.Dispatch>; readonly setTriggeringUserFilter: React.Dispatch>; - readonly showGantt: boolean; readonly showVersionIndicatorMode: VersionIndicatorOptions; readonly triggeringUserFilter: string | undefined; }; @@ -134,10 +133,8 @@ export const PanelButtons = ({ setRunAfterGte, setRunAfterLte, setRunTypeFilter, - setShowGantt, setShowVersionIndicatorMode, setTriggeringUserFilter, - showGantt, showVersionIndicatorMode, triggeringUserFilter, }: Props) => { @@ -158,7 +155,7 @@ export const PanelButtons = ({ setLimit(runLimit); }; - const enableResponsiveOptions = showGantt && Boolean(runId); + const enableResponsiveOptions = dagView === "gantt"; const { displayRunOptions, limit: defaultLimit } = getWidthBasedConfig( containerWidth, @@ -249,24 +246,30 @@ export const PanelButtons = ({ } }; - const dagViewOptions: Array> = useMemo( - () => [ - { - dataTestId: "grid-view-button", - label: , - title: translate("dag:panel.buttons.showGridShortcut"), - value: "grid", - }, - { - label: , - title: translate("dag:panel.buttons.showGraphShortcut"), - value: "graph", - }, - ], - [translate], - ); + const dagViewOptions: Array> = [ + { + dataTestId: "grid-view-button", + label: , + title: translate("dag:panel.buttons.showGridShortcut"), + value: "grid", + }, + ...(shouldShowToggleButtons + ? [ + { + label: , + title: translate("dag:panel.buttons.showGantt"), + value: "gantt" as const, + }, + ] + : []), + { + label: , + title: translate("dag:panel.buttons.showGraphShortcut"), + value: "graph", + }, + ]; - const handleDagViewChange = (view: "graph" | "grid") => { + const handleDagViewChange = (view: DagView) => { if (view === dagView) { handleFocus(view); } else { @@ -287,7 +290,7 @@ export const PanelButtons = ({ ); return ( - + @@ -537,13 +540,6 @@ export const PanelButtons = ({ value={runAfterRange} /> - {shouldShowToggleButtons ? ( - - setShowGantt(!showGantt)} size="sm"> - {translate("dag:panel.buttons.showGantt")} - - - ) : undefined} - {dagView === "grid" && ( + {dagView !== "graph" && (