This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new e5b07382047 Redo the gantt chart (#64335)
e5b07382047 is described below
commit e5b073820477cd6bd95a25273e210aa6cc595185
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Apr 15 13:49:28 2026 -0400
Redo the gantt chart (#64335)
* New gantt chart
* Fix scrollbox
* fix tries
* Address PR feedback
* Fix tooltip
* Hide queued/scheduled when less than 1s
* Fix boxes for scheduled and queued
* Clean up loops
* Switch to px length for when to show queued/scheduled
---
.../ui/src/components/TaskInstanceTooltip.tsx | 25 +-
.../Grid/DurationAxis.tsx => constants/dagView.ts} | 6 +-
.../ui/src/layouts/Details/DetailsLayout.tsx | 126 +++--
.../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 225 ++++----
.../layouts/Details/Gantt/GanttTimeline.test.tsx | 339 +++++++++++++
.../ui/src/layouts/Details/Gantt/GanttTimeline.tsx | 407 +++++++++++++++
.../ui/src/layouts/Details/Gantt/utils.test.ts | 303 +++++------
.../airflow/ui/src/layouts/Details/Gantt/utils.ts | 564 ++++++++++-----------
.../ui/src/layouts/Details/Grid/DurationAxis.tsx | 10 +-
.../airflow/ui/src/layouts/Details/Grid/Grid.tsx | 216 ++++----
.../layouts/Details/Grid/TaskInstancesColumn.tsx | 18 +-
.../ui/src/layouts/Details/Grid/TaskNames.tsx | 3 +-
.../ui/src/layouts/Details/Grid/constants.ts | 13 +-
.../Details/Grid/useGridRunsWithVersionFlags.ts | 44 +-
.../ui/src/layouts/Details/Grid/utils.test.ts | 59 +++
.../airflow/ui/src/layouts/Details/Grid/utils.ts | 35 ++
.../ui/src/layouts/Details/PanelButtons.tsx | 67 ++-
.../airflow/ui/src/pages/TaskInstance/Details.tsx | 12 +
18 files changed, 1662 insertions(+), 810 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 8162c02a7c3..603b37bc56b 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -28,8 +28,17 @@ import Time from "src/components/Time";
import { Tooltip, type TooltipProps } from "src/components/ui";
import { getDuration, renderDuration, sortStateEntries } from "src/utils";
+/** Grid summary plus optional schedule/queue hints (e.g. Gantt segment
tooltips). */
+type LightGridTaskInstanceSummaryWithWhen = {
+ readonly queued_when?: string | null;
+ readonly scheduled_when?: string | null;
+} & LightGridTaskInstanceSummary;
+
type Props = {
- readonly taskInstance?: LightGridTaskInstanceSummary |
TaskInstanceHistoryResponse | TaskInstanceResponse;
+ readonly taskInstance?:
+ | LightGridTaskInstanceSummaryWithWhen
+ | TaskInstanceHistoryResponse
+ | TaskInstanceResponse;
readonly tooltip?: string | null;
} & Omit<TooltipProps, "content">;
@@ -67,6 +76,20 @@ const TaskInstanceTooltip = ({ children, positioning,
taskInstance, tooltip, ...
{translate("runId")}: {taskInstance.dag_run_id}
</Text>
) : undefined}
+ {"scheduled_when" in taskInstance &&
+ taskInstance.scheduled_when !== null &&
+ taskInstance.scheduled_when !== "" ? (
+ <Text>
+ {translate("taskInstance.scheduledWhen")}: <Time
datetime={taskInstance.scheduled_when} />
+ </Text>
+ ) : undefined}
+ {"queued_when" in taskInstance &&
+ taskInstance.queued_when !== null &&
+ taskInstance.queued_when !== "" ? (
+ <Text>
+ {translate("taskInstance.queuedWhen")}: <Time
datetime={taskInstance.queued_when} />
+ </Text>
+ ) : undefined}
{"start_date" in taskInstance ? (
<>
{taskInstance.try_number > 1 && (
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
b/airflow-core/src/airflow/ui/src/constants/dagView.ts
similarity index 80%
copy from airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
copy to airflow-core/src/airflow/ui/src/constants/dagView.ts
index 0c798ab843a..6137c0423da 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
+++ b/airflow-core/src/airflow/ui/src/constants/dagView.ts
@@ -16,8 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, type BoxProps } from "@chakra-ui/react";
-export const DurationAxis = (props: BoxProps) => (
- <Box borderBottomWidth={1} left={0} position="absolute" right={0} zIndex={0}
{...props} />
-);
+/** The three available views for the main DAG panel. */
+export type DagView = "gantt" | "graph" | "grid";
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 dacd6171f5d..450e9cd73c6 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 { useEffect, useRef, useState } 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";
@@ -43,6 +43,7 @@ import { TriggerDAGButton } from
"src/components/TriggerDag/TriggerDAGButton";
import { ProgressBar } from "src/components/ui";
import { Toaster } from "src/components/ui";
import { Tooltip } from "src/components/ui/Tooltip";
+import type { DagView } from "src/constants/dagView";
import {
dagRunsLimitKey,
dagRunStateFilterKey,
@@ -51,11 +52,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 +65,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<HTMLDivElement | null>;
+}) => {
+ const { setHoveredTaskId } = useHover();
+
+ return (
+ <Box
+ height="100%"
+ minH={0}
+ minW={0}
+ onMouseLeave={() => setHoveredTaskId(undefined)}
+ overflowX="hidden"
+ overflowY="auto"
+ ref={scrollRef}
+ style={{ scrollbarGutter: "stable" }}
+ w="100%"
+ >
+ {children}
+ </Box>
+ );
+};
+
type Props = {
readonly error?: unknown;
readonly isLoading?: boolean;
@@ -77,7 +104,7 @@ 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<ImperativePanelGroupHandle | null>(null);
- const [dagView, setDagView] = useLocalStorage<"graph" |
"grid">(dagViewKey(dagId), defaultDagView);
+ const [dagView, setDagView] = useLocalStorage<DagView>(dagViewKey(dagId),
defaultDagView);
const [limit, setLimit] = useLocalStorage(dagRunsLimitKey(dagId), 10);
const [runAfterGte, setRunAfterGte] = useLocalStorage<string |
undefined>(runAfterGteKey(dagId), undefined);
const [runAfterLte, setRunAfterLte] = useLocalStorage<string |
undefined>(runAfterLteKey(dagId), undefined);
@@ -94,18 +121,28 @@ 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`,
VersionIndicatorOptions.ALL,
);
+
+ // Reset to grid when there is no runId. Remove this when we do gantt
averages
+ useEffect(() => {
+ if (!Boolean(runId) && dagView === "gantt") {
+ setDagView("grid");
+ }
+ }, [runId, dagView, setDagView]);
+
const { fitView, getZoom } = useReactFlow();
const { data: warningData } = useDagWarningServiceListDagWarnings({ dagId });
const { onClose, onOpen, open } = useDisclosure();
const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
const { i18n } = useTranslation();
const direction = i18n.dir();
+ const sharedGridGanttScrollRef = useRef<HTMLDivElement | null>(null);
+ // Treat "gantt" as "grid" for panel layout persistence so switching between
them doesn't reset sizes.
+ const panelViewKey = dagView === "gantt" ? "grid" : dagView;
return (
<HoverProvider>
@@ -149,19 +186,19 @@ export const DetailsLayout = ({ children, error,
isLoading, tabs }: Props) => {
</Tooltip>
) : undefined}
<PanelGroup
- autoSaveId={`${dagView}-${direction}`}
+ autoSaveId={`${panelViewKey}-${direction}`}
dir={direction}
direction="horizontal"
- key={`${dagView}-${direction}`}
+ key={`${panelViewKey}-${direction}`}
ref={panelGroupRef}
>
<Panel
defaultSize={dagView === "graph" ? 70 : 20}
id="main-panel"
- minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35
: 6}
+ minSize={dagView === "gantt" && Boolean(runId) ? 35 : 6}
order={1}
>
- <Box height="100%" position="relative">
+ <Flex flexDirection="column" height="100%">
<PanelButtons
dagRunStateFilter={dagRunStateFilter}
dagView={dagView}
@@ -176,40 +213,62 @@ export const DetailsLayout = ({ children, error,
isLoading, tabs }: Props) => {
setRunAfterGte={setRunAfterGte}
setRunAfterLte={setRunAfterLte}
setRunTypeFilter={setRunTypeFilter}
- setShowGantt={setShowGantt}
setShowVersionIndicatorMode={setShowVersionIndicatorMode}
setTriggeringUserFilter={setTriggeringUserFilter}
- showGantt={showGantt}
showVersionIndicatorMode={showVersionIndicatorMode}
triggeringUserFilter={triggeringUserFilter}
/>
- {dagView === "graph" ? (
- <Graph />
- ) : (
- <HStack alignItems="flex-start" gap={0}>
- <Grid
- dagRunState={dagRunStateFilter}
- limit={limit}
- runAfterGte={runAfterGte}
- runAfterLte={runAfterLte}
- runType={runTypeFilter}
- showGantt={Boolean(runId) && showGantt}
- showVersionIndicatorMode={showVersionIndicatorMode}
- triggeringUser={triggeringUserFilter}
- />
- {showGantt ? (
- <Gantt
+ <Box flex={1} minH={0} overflow="hidden">
+ {dagView === "graph" ? (
+ <Graph />
+ ) : dagView === "gantt" && Boolean(runId) ? (
+ <SharedScrollBox scrollRef={sharedGridGanttScrollRef}>
+ <Flex alignItems="flex-start" gap={0} maxW="100%"
minW={0} overflow="clip" w="100%">
+ <Grid
+ dagRunState={dagRunStateFilter}
+ limit={limit}
+ runAfterGte={runAfterGte}
+ runAfterLte={runAfterLte}
+ runType={runTypeFilter}
+ sharedScrollContainerRef={sharedGridGanttScrollRef}
+ showGantt
+ showVersionIndicatorMode={showVersionIndicatorMode}
+ triggeringUser={triggeringUserFilter}
+ />
+ <Gantt
+ dagRunState={dagRunStateFilter}
+ limit={limit}
+ runAfterGte={runAfterGte}
+ runAfterLte={runAfterLte}
+ runType={runTypeFilter}
+ sharedScrollContainerRef={sharedGridGanttScrollRef}
+ triggeringUser={triggeringUserFilter}
+ />
+ </Flex>
+ </SharedScrollBox>
+ ) : (
+ <HStack
+ alignItems="flex-start"
+ gap={0}
+ height="100%"
+ maxW="100%"
+ minW={0}
+ overflow="hidden"
+ w="100%"
+ >
+ <Grid
dagRunState={dagRunStateFilter}
limit={limit}
runAfterGte={runAfterGte}
runAfterLte={runAfterLte}
runType={runTypeFilter}
+ showVersionIndicatorMode={showVersionIndicatorMode}
triggeringUser={triggeringUserFilter}
/>
- ) : undefined}
- </HStack>
- )}
- </Box>
+ </HStack>
+ )}
+ </Box>
+ </Flex>
</Panel>
{!isRightPanelCollapsed && (
<>
@@ -232,7 +291,6 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
justifyContent="center"
position="relative"
w={0.5}
- // onClick={(e) => console.log(e)}
/>
</PanelResizeHandle>
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 62de32cdb98..069976239a6 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,57 +16,32 @@
* 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;
@@ -74,24 +49,29 @@ type Props = {
readonly runAfterGte?: string | undefined;
readonly runAfterLte?: string | undefined;
readonly runType?: DagRunType | undefined;
+ readonly sharedScrollContainerRef?: RefObject<HTMLDivElement | null>;
readonly triggeringUser?: string | undefined;
};
-const CHART_PADDING = 36;
-const CHART_ROW_HEIGHT = 20;
-const MIN_BAR_WIDTH = 10;
-
-export const Gantt = ({ dagRunState, limit, runAfterGte, runAfterLte, runType,
triggeringUser }: Props) => {
- const { dagId = "", groupId: selectedGroupId, runId = "", taskId:
selectedTaskId } = useParams();
+export const Gantt = ({
+ dagRunState,
+ limit,
+ runAfterGte,
+ runAfterLte,
+ runType,
+ sharedScrollContainerRef,
+ triggeringUser,
+}: Props) => {
+ const standaloneScrollRef = useRef<HTMLDivElement | null>(null);
+ const usesSharedScroll = sharedScrollContainerRef !== undefined;
+
+ 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";
@@ -99,20 +79,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte,
runAfterLte, runType, t
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,
@@ -134,7 +100,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte,
runAfterLte, runType, t
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] : [],
@@ -143,7 +108,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte,
runAfterLte, runType, t
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,
@@ -154,95 +118,80 @@ export const Gantt = ({ dagRunState, limit, runAfterGte,
runAfterLte, runType, t
},
);
- 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({
+ ganttItems: ganttDataItems,
selectedRun,
selectedTimezone,
- translate,
});
+ const virtualizerScrollPaddingStart = usesSharedScroll
+ ? GANTT_ROW_OFFSET_PX
+ : GANTT_STANDALONE_VIRTUALIZER_PADDING_START_PX;
+
if (runId === "" || (!isLoading && !selectedRun)) {
return undefined;
}
- const handleChartMouseLeave = () => {
+ const handleStandaloneMouseLeave = () => {
setHoveredTaskId(undefined);
-
- // Clear all hover styles when mouse leaves the chart area
- const allTasks = document.querySelectorAll<HTMLDivElement>('[id*="-"]');
-
- allTasks.forEach((task) => {
- task.style.backgroundColor = "";
- });
};
- return (
- <Box
- height={`${fixedHeight}px`}
- minW="250px"
- ml={-2}
- mt={`${GRID_BODY_OFFSET_PX}px`}
- onMouseLeave={handleChartMouseLeave}
- w="100%"
- >
- <Bar
- data={chartData}
- options={chartOptions}
- style={{
- paddingTop: flatNodes.length === 1 ? 15 : 1.5,
- }}
+ const timeline =
+ Boolean(selectedRun) && dagId ? (
+ <GanttTimeline
+ dagId={dagId}
+ flatNodes={flatNodes}
+ ganttDataItems={ganttDataItems}
+ gridSummaries={gridSummaries}
+ maxMs={maxMs}
+ minMs={minMs}
+ onSegmentClick={() => setMode(NavigationModes.TI)}
+ rowSegments={rowSegments}
+ runId={runId}
+ scrollContainerRef={scrollContainerRef}
+ virtualizerScrollPaddingStart={virtualizerScrollPaddingStart}
/>
- </Box>
+ ) : undefined;
+
+ if (usesSharedScroll) {
+ return (
+ <Flex flex={1} flexDirection="column" maxW="100%" minW={0}
overflow="clip" pt={0}>
+ {timeline}
+ </Flex>
+ );
+ }
+
+ return (
+ <Flex flex={1} flexDirection="column" maxW="100%" minH={0} minW={0}
overflow="hidden">
+ <Box
+ flex={1}
+ minH={0}
+ minW={0}
+ onMouseLeave={handleStandaloneMouseLeave}
+ overflowX="hidden"
+ overflowY="auto"
+ ref={standaloneScrollRef}
+ >
+ {timeline}
+ </Box>
+ </Flex>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
new file mode 100644
index 00000000000..ad492249978
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
@@ -0,0 +1,339 @@
+/* eslint-disable max-lines */
+
+/* eslint-disable unicorn/no-null */
+
+/*!
+ * 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 { act, render, screen } from "@testing-library/react";
+import type { PropsWithChildren, RefObject } from "react";
+import { createRef } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { HoverProvider } from "src/context/hover";
+import { ROW_HEIGHT } from "src/layouts/Details/Grid/constants";
+import type { GridTask } from "src/layouts/Details/Grid/utils";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { GanttTimeline } from "./GanttTimeline";
+import type { GanttDataItem } from "./utils";
+
+// Mock the virtualizer so all items render regardless of scroll-container
dimensions
+// (happy-dom does not perform layout, so getScrollElement always has height
0).
+vi.mock("@tanstack/react-virtual", () => ({
+ useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: ()
=> number }) => ({
+ getTotalSize: () => count * estimateSize(),
+ getVirtualItems: () =>
+ Array.from({ length: count }, (_, index) => ({
+ index,
+ key: String(index),
+ lane: 0,
+ size: estimateSize(),
+ start: index * estimateSize(),
+ })),
+ }),
+}));
+
+const TestWrapper = ({ children }: PropsWithChildren) => (
+ <Wrapper>
+ <HoverProvider>{children}</HoverProvider>
+ </Wrapper>
+);
+
+// Shared time range: 10:00 → 10:10 UTC on 2024-03-14
+const MIN_MS = new Date("2024-03-14T10:00:00Z").getTime();
+const MAX_MS = new Date("2024-03-14T10:10:00Z").getTime();
+
+const BASE_NODE: GridTask = {
+ depth: 0,
+ id: "task_1",
+ is_mapped: false,
+ label: "task_1",
+} as GridTask;
+
+const makeScrollRef = (): RefObject<HTMLDivElement | null> => {
+ const ref = createRef<HTMLDivElement | null>();
+
+ (ref as { current: HTMLDivElement | null }).current =
document.createElement("div");
+
+ return ref;
+};
+
+const defaultProps = {
+ dagId: "test_dag",
+ flatNodes: [BASE_NODE],
+ ganttDataItems: [] as Array<GanttDataItem>,
+ gridSummaries: [],
+ maxMs: MAX_MS,
+ minMs: MIN_MS,
+ rowSegments: [[]] as Array<Array<GanttDataItem>>,
+ runId: "run_1",
+ virtualizerScrollPaddingStart: ROW_HEIGHT,
+};
+
+describe("GanttTimeline segment bars", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("renders a single execution bar when only start_date is present", () => {
+ const executionSegment: GanttDataItem = {
+ queued_when: null,
+ scheduled_when: null,
+ state: "success",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T10:00:00Z").getTime(), new
Date("2024-03-14T10:05:00Z").getTime()],
+ y: "task_1",
+ };
+
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ ganttDataItems={[executionSegment]}
+ rowSegments={[[executionSegment]]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ // One link per bar — only the execution bar should appear
+ expect(screen.getAllByRole("link")).toHaveLength(1);
+ });
+
+ it("renders two bars (queued + execution) when queued_dttm is present", ()
=> {
+ const queuedSegment: GanttDataItem = {
+ queued_when: "2024-03-14T09:59:00Z",
+ scheduled_when: null,
+ state: "queued",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T09:59:00Z").getTime(), new
Date("2024-03-14T10:00:00Z").getTime()],
+ y: "task_1",
+ };
+ const executionSegment: GanttDataItem = {
+ queued_when: "2024-03-14T09:59:00Z",
+ scheduled_when: null,
+ state: "success",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T10:00:00Z").getTime(), new
Date("2024-03-14T10:05:00Z").getTime()],
+ y: "task_1",
+ };
+ const segments = [queuedSegment, executionSegment];
+
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ ganttDataItems={segments}
+ rowSegments={[segments]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ expect(screen.getAllByRole("link")).toHaveLength(2);
+ });
+
+ it("renders three bars (scheduled + queued + execution) when both
scheduled_dttm and queued_dttm are present", () => {
+ const scheduledSegment: GanttDataItem = {
+ queued_when: "2024-03-14T09:59:00Z",
+ scheduled_when: "2024-03-14T09:58:00Z",
+ state: "scheduled",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T09:58:00Z").getTime(), new
Date("2024-03-14T09:59:00Z").getTime()],
+ y: "task_1",
+ };
+ const queuedSegment: GanttDataItem = {
+ queued_when: "2024-03-14T09:59:00Z",
+ scheduled_when: "2024-03-14T09:58:00Z",
+ state: "queued",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T09:59:00Z").getTime(), new
Date("2024-03-14T10:00:00Z").getTime()],
+ y: "task_1",
+ };
+ const executionSegment: GanttDataItem = {
+ queued_when: "2024-03-14T09:59:00Z",
+ scheduled_when: "2024-03-14T09:58:00Z",
+ state: "success",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T10:00:00Z").getTime(), new
Date("2024-03-14T10:05:00Z").getTime()],
+ y: "task_1",
+ };
+ const segments = [scheduledSegment, queuedSegment, executionSegment];
+
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ ganttDataItems={segments}
+ rowSegments={[segments]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ expect(screen.getAllByRole("link")).toHaveLength(3);
+ });
+
+ it("renders bars for multiple tasks independently", () => {
+ const node2: GridTask = { depth: 0, id: "task_2", is_mapped: false, label:
"task_2" } as GridTask;
+
+ // task_1: scheduled + execution (2 bars)
+ const t1Scheduled: GanttDataItem = {
+ queued_when: null,
+ scheduled_when: "2024-03-14T09:58:00Z",
+ state: "scheduled",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T09:58:00Z").getTime(), new
Date("2024-03-14T10:00:00Z").getTime()],
+ y: "task_1",
+ };
+ const t1Exec: GanttDataItem = {
+ queued_when: null,
+ scheduled_when: "2024-03-14T09:58:00Z",
+ state: "failed",
+ taskId: "task_1",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T10:00:00Z").getTime(), new
Date("2024-03-14T10:03:00Z").getTime()],
+ y: "task_1",
+ };
+
+ // task_2: execution only (1 bar)
+ const t2Exec: GanttDataItem = {
+ queued_when: null,
+ scheduled_when: null,
+ state: "success",
+ taskId: "task_2",
+ tryNumber: 1,
+ x: [new Date("2024-03-14T10:04:00Z").getTime(), new
Date("2024-03-14T10:08:00Z").getTime()],
+ y: "task_2",
+ };
+
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ flatNodes={[BASE_NODE, node2]}
+ ganttDataItems={[t1Scheduled, t1Exec, t2Exec]}
+ rowSegments={[[t1Scheduled, t1Exec], [t2Exec]]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ // 2 bars for task_1 + 1 bar for task_2 = 3 total
+ expect(screen.getAllByRole("link")).toHaveLength(3);
+ });
+
+ it("renders no bars when rowSegments is empty", () => {
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ flatNodes={[BASE_NODE]}
+ ganttDataItems={[]}
+ rowSegments={[[]]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ expect(screen.queryAllByRole("link")).toHaveLength(0);
+ });
+
+ it("hides scheduled and queued bars whose pixel width is below
MIN_SEGMENT_RENDER_PX (5 px)", () => {
+ // Simulate a 1000 px-wide Gantt body via a ResizeObserver mock.
+ // spanMs = MAX_MS − MIN_MS = 600 000 ms (10 min)
+ // A 2-second bar → (2 000 / 600 000) * 1 000 ≈ 3.33 px < 5 px threshold
→ hidden
+ // A 5-minute execution bar → (300 000 / 600 000) * 1 000 = 500 px →
visible
+
+ let observerCallback: ResizeObserverCallback | undefined;
+
+ vi.stubGlobal(
+ "ResizeObserver",
+ class MockResizeObserver {
+ public constructor(cb: ResizeObserverCallback) {
+ observerCallback = cb;
+ }
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+ public disconnect() {
+ /* empty */
+ }
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+ public observe() {
+ /* empty */
+ }
+ // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+ public unobserve() {
+ /* empty */
+ }
+ },
+ );
+
+ const scheduledSegment: GanttDataItem = {
+ queued_when: new Date(MIN_MS + 2000).toISOString(),
+ scheduled_when: new Date(MIN_MS).toISOString(),
+ state: "scheduled",
+ taskId: "task_1",
+ tryNumber: 1,
+ // 2 s wide — below the 5 px threshold at 1000 px viewport
+ x: [MIN_MS, MIN_MS + 2000],
+ y: "task_1",
+ };
+ const queuedSegment: GanttDataItem = {
+ queued_when: new Date(MIN_MS + 2000).toISOString(),
+ scheduled_when: new Date(MIN_MS).toISOString(),
+ state: "queued",
+ taskId: "task_1",
+ tryNumber: 1,
+ // 2 s wide — below the 5 px threshold at 1000 px viewport
+ x: [MIN_MS + 2000, MIN_MS + 4000],
+ y: "task_1",
+ };
+ const executionSegment: GanttDataItem = {
+ queued_when: new Date(MIN_MS + 2000).toISOString(),
+ scheduled_when: new Date(MIN_MS).toISOString(),
+ state: "success",
+ taskId: "task_1",
+ tryNumber: 1,
+ // 5 min wide — well above threshold
+ x: [MIN_MS + 4000, MIN_MS + 304_000],
+ y: "task_1",
+ };
+ const segments = [scheduledSegment, queuedSegment, executionSegment];
+
+ render(
+ <GanttTimeline
+ {...defaultProps}
+ ganttDataItems={segments}
+ rowSegments={[segments]}
+ scrollContainerRef={makeScrollRef()}
+ />,
+ { wrapper: TestWrapper },
+ );
+
+ // Fire the ResizeObserver callback to set bodyWidthPx = 1000, enabling
the filter.
+ act(() => {
+ observerCallback?.([{ contentRect: { width: 1000 } } as
ResizeObserverEntry], {} as ResizeObserver);
+ });
+
+ // Only the execution bar should be rendered; scheduled and queued are too
narrow.
+ expect(screen.getAllByRole("link")).toHaveLength(1);
+ });
+});
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 00000000000..fd5949e070f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
@@ -0,0 +1,407 @@
+/*!
+ * 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 dayjs from "dayjs";
+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,
+ buildMaxTryByTaskId,
+ getGanttSegmentTo,
+ gridSummariesToTaskIdMap,
+} from "./utils";
+
+/** Size of the state icon rendered inside each Gantt bar (px). The minimum
bar width is derived
+ * from this value so the icon is never clipped when a task has a very short
duration. */
+const GANTT_STATE_ICON_SIZE_PX = 10;
+const MIN_BAR_WIDTH_PX = GANTT_STATE_ICON_SIZE_PX;
+
+/** Scheduled and queued bars narrower than this are not rendered — they would
be invisible anyway
+ * and their presence would suppress the rounded left edge of the adjacent
execution bar. */
+const MIN_SEGMENT_RENDER_PX = 5;
+
+/** Minimum horizontal gap (px) between time-axis labels before one is
dropped. */
+const MIN_TICK_SPACING_PX = 80;
+
+/** 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<GridTask>;
+ readonly ganttDataItems: Array<GanttDataItem>;
+ readonly gridSummaries: Array<LightGridTaskInstanceSummary>;
+ readonly maxMs: number;
+ readonly minMs: number;
+ readonly onSegmentClick?: () => void;
+ readonly rowSegments: Array<Array<GanttDataItem>>;
+ readonly runId: string;
+ readonly scrollContainerRef: RefObject<HTMLDivElement | null>;
+ /** scrollPaddingStart for @tanstack/react-virtual (116 standalone, 180 with
shared outer padding). */
+ readonly virtualizerScrollPaddingStart: number;
+};
+
+const toTooltipSummary = (
+ segment: GanttDataItem,
+ node: GridTask,
+ gridSummary: LightGridTaskInstanceSummary | undefined,
+) => {
+ if (gridSummary !== undefined && (node.isGroup ?? node.is_mapped)) {
+ return gridSummary;
+ }
+
+ return {
+ // eslint-disable-next-line unicorn/no-null
+ child_states: null,
+ max_end_date: dayjs(segment.x[1]).toISOString(),
+ min_start_date: dayjs(segment.x[0]).toISOString(),
+ // eslint-disable-next-line unicorn/no-null
+ state: segment.state ?? null,
+ task_display_name: segment.y,
+ task_id: segment.taskId,
+ try_number: segment.tryNumber,
+ ...(segment.tryNumber === undefined
+ ? {}
+ : {
+ queued_when: segment.queued_when,
+ scheduled_when: segment.scheduled_when,
+ }),
+ };
+};
+
+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<HTMLDivElement | null>(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);
+ // Precompute max try per task once (O(n)) so getGanttSegmentTo can do O(1)
lookups.
+ const maxTryByTaskId = buildMaxTryByTaskId(ganttDataItems);
+ 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
MIN_TICK_SPACING_PX per tick.
+ 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();
+
+ // Hoist out of the per-segment render loop — location.pathname and
location.search are
+ // constant within a single render, so parsing them once avoids O(segments)
string parsing.
+ const { pathname, search } = location;
+ const baseSearchParams = new URLSearchParams(search);
+
+ 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),
+ widthPx,
+ };
+ };
+
+ return (
+ <Box
+ maxW="100%"
+ minW={0}
+ overflow="clip"
+ position="relative"
+ style={{ isolation: "isolate" }}
+ w="100%"
+ zIndex={0}
+ >
+ <Flex
+ bg="bg"
+ flexDirection="column"
+ flexShrink={0}
+ h={`${GANTT_TOP_PADDING_PX + GANTT_AXIS_HEIGHT_PX}px`}
+ position="sticky"
+ px={0}
+ top={0}
+ zIndex={3}
+ >
+ <Box aria-hidden flexShrink={0} h={`${GANTT_TOP_PADDING_PX}px`} />
+ <Box
+ borderBottomWidth={1}
+ borderColor="border"
+ flexShrink={0}
+ h={`${GANTT_AXIS_HEIGHT_PX}px`}
+ position="relative"
+ w="100%"
+ >
+ {timeTicks.map(({ label, labelAlign, leftPct }) => (
+ <Fragment key={`gantt-time-tick-${leftPct}`}>
+ <Box
+ aria-hidden
+ borderColor="border"
+ borderLeftWidth={1}
+ bottom={0}
+ h={`${GANTT_AXIS_TICK_HEIGHT_PX}px`}
+ left={`calc(${leftPct}% - 0.25px)`}
+ position="absolute"
+ />
+ <Text
+ bottom={`${GANTT_AXIS_TICK_HEIGHT_PX + 2}px`}
+ color="border.emphasized"
+ fontSize="xs"
+ left={`${leftPct}%`}
+ lineHeight={1}
+ position="absolute"
+ textAlign={labelAlign}
+ transform={
+ labelAlign === "left"
+ ? "translateX(0)"
+ : labelAlign === "right"
+ ? "translateX(-100%)"
+ : "translateX(-50%)"
+ }
+ whiteSpace="nowrap"
+ >
+ {label}
+ </Text>
+ </Fragment>
+ ))}
+ </Box>
+ </Flex>
+
+ <Box maxW="100%" minW={0} overflow="hidden" position="relative"
ref={bodyRef} w="100%" zIndex={0}>
+ <Box h={`${rowVirtualizer.getTotalSize()}px`} maxW="100%"
position="relative" w="100%">
+ <Box
+ aria-hidden
+ h="100%"
+ left={0}
+ pointerEvents="none"
+ position="absolute"
+ top={0}
+ w="100%"
+ zIndex={0}
+ >
+ {timeTicks.map(({ leftPct }) => (
+ <Box
+ borderColor="border"
+ borderLeftWidth={1}
+ h="100%"
+ key={`gantt-body-grid-${leftPct}`}
+ left={`calc(${leftPct}% - 0.25px)`}
+ position="absolute"
+ top={0}
+ w={0}
+ />
+ ))}
+ </Box>
+ {virtualItems.map((vItem) => {
+ const node = flatNodes[vItem.index];
+
+ if (node === undefined) {
+ return undefined;
+ }
+
+ const allSegments = rowSegments[vItem.index] ?? [];
+ // Hide scheduled/queued bars that are too narrow to see.
Re-derive adjacency
+ // from the filtered list so the adjacent execution bar keeps
rounded corners.
+ const segments =
+ bodyWidthPx > 0
+ ? allSegments.filter((segment) =>
+ segment.state !== "scheduled" && segment.state !== "queued"
+ ? true
+ : segmentLayout(segment).widthPx >=
MIN_SEGMENT_RENDER_PX,
+ )
+ : allSegments;
+ const taskId = node.id;
+ const isSelected = selectedTaskId === taskId || selectedGroupId
=== taskId;
+ const isHovered = hoveredTaskId === taskId;
+ const gridSummary = summaryByTaskId.get(taskId);
+
+ return (
+ <Box
+ borderBottomWidth={1}
+ borderColor={node.isGroup ? "border.emphasized" : "border"}
+ h={`${ROW_HEIGHT}px`}
+ key={taskId}
+ left={0}
+ maxW="100%"
+ position="absolute"
+ top={0}
+ transform={`translateY(${vItem.start}px)`}
+ w="100%"
+ zIndex={1}
+ >
+ <Box
+ bg={isSelected ? "brand.emphasized" : isHovered ?
"brand.muted" : undefined}
+ h="100%"
+ maxW="100%"
+ onMouseEnter={() => 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,
+ item: segment,
+ maxTryByTaskId,
+ pathname,
+ runId,
+ searchParams: baseSearchParams,
+ });
+ const { state, tryNumber, x } = segment;
+ const tooltipInstance = toTooltipSummary(segment, node,
gridSummary);
+ const barRadius = 4;
+
+ // Task groups don't have a try number
+ const touchesNext =
+ tryNumber === undefined ? false : segments[segIndex +
1]?.tryNumber === tryNumber;
+ const touchesPrev =
+ tryNumber === undefined ? false : segments[segIndex -
1]?.tryNumber === tryNumber;
+
+ return (
+ <TaskInstanceTooltip
+ key={`${taskId}-${tryNumber ?? -1}-${x[0]}`}
+ openDelay={500}
+ positioning={{
+ offset: { crossAxis: 0, mainAxis: 5 },
+ placement: "bottom",
+ }}
+ taskInstance={tooltipInstance}
+ >
+ <Box
+ as="span"
+ display="block"
+ h={`${TASK_BAR_HEIGHT_PX}px`}
+ left={`${leftPct}%`}
+ maxW={`${100 - leftPct}%`}
+ minW={`${MIN_BAR_WIDTH_PX}px`}
+ position="absolute"
+ top={`${(ROW_HEIGHT - TASK_BAR_HEIGHT_PX) / 2}px`}
+ w={`${widthPct}%`}
+ zIndex={segIndex + 1}
+ >
+ <Link
+ onClick={() => onSegmentClick?.()}
+ replace
+ style={{ display: "block", height: "100%", width:
"100%" }}
+ to={to ?? ""}
+ >
+ <Badge
+ alignItems="center"
+ borderBottomLeftRadius={touchesPrev ? 0 :
barRadius}
+ borderBottomRightRadius={touchesNext ? 0 :
barRadius}
+ borderTopLeftRadius={touchesPrev ? 0 : barRadius}
+ borderTopRightRadius={touchesNext ? 0 :
barRadius}
+ colorPalette={state ?? "none"}
+ display="flex"
+ h="100%"
+ justifyContent="center"
+ minH={0}
+ p={0}
+ variant="solid"
+ w="100%"
+ >
+ {touchesNext ? undefined : (
+ <StateIcon size={GANTT_STATE_ICON_SIZE_PX}
state={state} />
+ )}
+ </Badge>
+ </Link>
+ </Box>
+ </TaskInstanceTooltip>
+ );
+ })}
+ </Box>
+ </Box>
+ );
+ })}
+ </Box>
+ </Box>
+ </Box>
+ );
+};
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
index a0e61cc8579..1fc70a8a1ab 100644
--- 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
@@ -1,5 +1,7 @@
/* eslint-disable max-lines */
+/* eslint-disable unicorn/no-null */
+
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@@ -18,162 +20,118 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { ChartEvent, ActiveElement } from "chart.js";
import dayjs from "dayjs";
-import type { TFunction } from "i18next";
-import { describe, it, expect } from "vitest";
-
-import type { GanttDataItem } from "./utils";
-import { createChartOptions, transformGanttData } from "./utils";
-
-// eslint-disable-next-line no-empty-function,
@typescript-eslint/no-empty-function
-const noop = () => {};
-
-const defaultChartParams = {
- gridColor: "#ccc",
- handleBarClick: noop as (event: ChartEvent, elements: Array<ActiveElement>)
=> void,
- handleBarHover: noop as (event: ChartEvent, elements: Array<ActiveElement>)
=> void,
- hoveredId: undefined,
- hoveredItemColor: "#eee",
- labels: ["task_1", "task_2"],
- selectedId: undefined,
- selectedItemColor: "#ddd",
- selectedTimezone: "UTC",
- translate: ((key: string) => key) as unknown as TFunction,
-};
-
-describe("createChartOptions", () => {
- describe("x-axis scale min/max with ISO date strings", () => {
- it("should compute valid min/max for completed tasks with ISO dates", ()
=> {
- const data: Array<GanttDataItem> = [
- {
- state: "success",
- taskId: "task_1",
- x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
- y: "task_1",
- },
- {
- state: "success",
- taskId: "task_2",
- x: ["2024-03-14T10:03:00.000Z", "2024-03-14T10:10:00.000Z"],
- y: "task_2",
- },
- ];
-
- const options = createChartOptions({
- ...defaultChartParams,
- data,
- selectedRun: {
- dag_id: "test_dag",
- duration: 600,
- end_date: "2024-03-14T10:10:00+00:00",
- has_missed_deadline: false,
- queued_at: "2024-03-14T09:59:00+00:00",
- run_after: "2024-03-14T10:00:00+00:00",
- run_id: "run_1",
- run_type: "manual",
- start_date: "2024-03-14T10:00:00+00:00",
- state: "success",
- },
- });
+import { describe, expect, it } from "vitest";
- const xScale = options.scales.x;
+import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import type { GridTask } from "src/layouts/Details/Grid/utils";
- expect(xScale.min).toBeTypeOf("number");
- expect(xScale.max).toBeTypeOf("number");
- expect(Number.isNaN(xScale.min)).toBe(false);
- expect(Number.isNaN(xScale.max)).toBe(false);
- // max should be slightly beyond the latest end date (5% padding)
- expect(xScale.max).toBeGreaterThan(new
Date("2024-03-14T10:10:00.000Z").getTime());
- });
+import {
+ type GanttDataItem,
+ buildGanttRowSegments,
+ buildGanttTimeAxisTicks,
+ buildMaxTryByTaskId,
+ GANTT_TIME_AXIS_TICK_COUNT,
+ gridSummariesToTaskIdMap,
+ transformGanttData,
+} from "./utils";
- it("should compute valid min/max for running tasks", () => {
- const now = dayjs().toISOString();
- const data: Array<GanttDataItem> = [
- {
- state: "success",
- taskId: "task_1",
- x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
- y: "task_1",
- },
- {
- state: "running",
- taskId: "task_2",
- x: ["2024-03-14T10:05:00.000Z", now],
- y: "task_2",
- },
- ];
-
- const options = createChartOptions({
- ...defaultChartParams,
- data,
- selectedRun: {
- dag_id: "test_dag",
- duration: 0,
- // eslint-disable-next-line unicorn/no-null
- end_date: null,
- has_missed_deadline: false,
- queued_at: "2024-03-14T09:59:00+00:00",
- run_after: "2024-03-14T10:00:00+00:00",
- run_id: "run_1",
- run_type: "manual",
- start_date: "2024-03-14T10:00:00+00:00",
- state: "running",
- },
- });
+describe("buildGanttTimeAxisTicks", () => {
+ it("returns evenly spaced elapsed labels with edge alignment", () => {
+ const minMs = 0;
+ const maxMs = 60_000;
+ const ticks = buildGanttTimeAxisTicks(minMs, maxMs);
- const xScale = options.scales.x;
+ 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);
+ });
- expect(xScale.min).toBeTypeOf("number");
- expect(xScale.max).toBeTypeOf("number");
- expect(Number.isNaN(xScale.min)).toBe(false);
- expect(Number.isNaN(xScale.max)).toBe(false);
- });
+ it("supports a single tick", () => {
+ const ticks = buildGanttTimeAxisTicks(1000, 1000, 1);
- it("should handle empty data with running DagRun (fallback to formatted
dates)", () => {
- const options = createChartOptions({
- ...defaultChartParams,
- data: [],
- labels: [],
- selectedRun: {
- dag_id: "test_dag",
- duration: 0,
- // eslint-disable-next-line unicorn/no-null
- end_date: null,
- has_missed_deadline: false,
- queued_at: "2024-03-14T09:59:00+00:00",
- run_after: "2024-03-14T10:00:00+00:00",
- run_id: "run_1",
- run_type: "manual",
- start_date: "2024-03-14T10:00:00+00:00",
- state: "running",
- },
- });
+ expect(ticks).toHaveLength(1);
+ expect(ticks[0]?.leftPct).toBe(0);
+ expect(ticks[0]?.labelAlign).toBe("left");
+ expect(ticks[0]?.label).toBe("00:00:00");
+ });
+});
- const xScale = options.scales.x;
+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);
- // With empty data, min/max are formatted date strings (fallback branch)
- expect(xScale.min).toBeTypeOf("string");
- expect(xScale.max).toBeTypeOf("string");
- });
+ expect(map.get("a")).toBe(summaries[0]);
+ expect(map.get("b")).toBe(summaries[1]);
+ expect(map.size).toBe(2);
+ });
+});
+
+describe("buildMaxTryByTaskId", () => {
+ it("returns the maximum try number for each task", () => {
+ const items: Array<GanttDataItem> = [
+ { taskId: "t1", tryNumber: 1, x: [0, 1], y: "t1" },
+ { taskId: "t1", tryNumber: 3, x: [0, 1], y: "t1" },
+ { taskId: "t1", tryNumber: 2, x: [0, 1], y: "t1" },
+ { taskId: "t2", tryNumber: 1, x: [0, 1], y: "t2" },
+ ];
+ const map = buildMaxTryByTaskId(items);
+
+ expect(map.get("t1")).toBe(3);
+ expect(map.get("t2")).toBe(1);
+ });
+
+ it("defaults to 1 when tryNumber is undefined", () => {
+ const items: Array<GanttDataItem> = [{ taskId: "t1", x: [0, 1], y: "t1" }];
+ const map = buildMaxTryByTaskId(items);
+
+ expect(map.get("t1")).toBe(1);
+ });
+
+ it("returns an empty map for empty input", () => {
+ expect(buildMaxTryByTaskId([]).size).toBe(0);
+ });
+});
+
+describe("buildGanttRowSegments", () => {
+ it("groups items by task id in flat node order", () => {
+ const flatNodes: Array<GridTask> = [
+ { 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<GanttDataItem> = [
+ { 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"]);
});
});
describe("transformGanttData", () => {
- it("should skip tasks with null start_date", () => {
+ it("returns no segments when the try has no schedule, queue, or start time",
() => {
const result = transformGanttData({
allTries: [
{
- // eslint-disable-next-line unicorn/no-null
end_date: null,
is_mapped: false,
- // eslint-disable-next-line unicorn/no-null
queued_dttm: null,
- // eslint-disable-next-line unicorn/no-null
scheduled_dttm: null,
- // eslint-disable-next-line unicorn/no-null
start_date: null,
- // eslint-disable-next-line unicorn/no-null
state: null,
task_display_name: "task_1",
task_id: "task_1",
@@ -187,17 +145,14 @@ describe("transformGanttData", () => {
expect(result).toHaveLength(0);
});
- it("should include running tasks with valid start_date and use current time
as end", () => {
+ it("includes running tasks with valid start_date and uses current time as
end", () => {
const before = dayjs();
const result = transformGanttData({
allTries: [
{
- // eslint-disable-next-line unicorn/no-null
end_date: null,
is_mapped: false,
- // eslint-disable-next-line unicorn/no-null
queued_dttm: null,
- // eslint-disable-next-line unicorn/no-null
scheduled_dttm: null,
start_date: "2024-03-14T10:00:00+00:00",
state: "running",
@@ -212,25 +167,20 @@ describe("transformGanttData", () => {
expect(result).toHaveLength(1);
expect(result[0]?.state).toBe("running");
- // End time should be approximately now (ISO string)
- const endTime = dayjs(result[0]?.x[1]);
+ const endTime = result[0]?.x[1] ?? 0;
- expect(endTime.valueOf()).toBeGreaterThanOrEqual(before.valueOf());
+ expect(endTime).toBeGreaterThanOrEqual(before.valueOf());
});
- it("should skip groups with null min_start_date or max_end_date", () => {
+ it("skips groups with null min_start_date or max_end_date", () => {
const result = transformGanttData({
allTries: [],
flatNodes: [{ depth: 0, id: "group_1", is_mapped: false, isGroup: true,
label: "group_1" }],
gridSummaries: [
{
- // eslint-disable-next-line unicorn/no-null
child_states: null,
- // eslint-disable-next-line unicorn/no-null
max_end_date: null,
- // eslint-disable-next-line unicorn/no-null
min_start_date: null,
- // eslint-disable-next-line unicorn/no-null
state: null,
task_display_name: "group_1",
task_id: "group_1",
@@ -241,15 +191,13 @@ describe("transformGanttData", () => {
expect(result).toHaveLength(0);
});
- it("should produce ISO date strings parseable by dayjs", () => {
+ it("uses millisecond timestamps for segment bounds", () => {
const result = transformGanttData({
allTries: [
{
end_date: "2024-03-14T10:05:00+00:00",
is_mapped: false,
- // eslint-disable-next-line unicorn/no-null
queued_dttm: null,
- // eslint-disable-next-line unicorn/no-null
scheduled_dttm: null,
start_date: "2024-03-14T10:00:00+00:00",
state: "success",
@@ -263,17 +211,12 @@ describe("transformGanttData", () => {
});
expect(result).toHaveLength(1);
- // x values should be valid ISO strings that dayjs can parse without NaN
- const start = dayjs(result[0]?.x[0]);
- const end = dayjs(result[0]?.x[1]);
-
- expect(start.isValid()).toBe(true);
- expect(end.isValid()).toBe(true);
- expect(Number.isNaN(start.valueOf())).toBe(false);
- expect(Number.isNaN(end.valueOf())).toBe(false);
+ expect(Number.isFinite(result[0]?.x[0])).toBe(true);
+ expect(Number.isFinite(result[0]?.x[1])).toBe(true);
+ expect(result[0]?.x[1]).toBeGreaterThanOrEqual(result[0]?.x[0] ?? 0);
});
- it("should produce 3 segments when scheduled_dttm and queued_dttm are
present", () => {
+ it("produces 3 segments when scheduled_dttm and queued_dttm are present", ()
=> {
const result = transformGanttData({
allTries: [
{
@@ -298,14 +241,13 @@ describe("transformGanttData", () => {
expect(result[2]?.state).toBe("success");
});
- it("should produce 2 segments when only queued_dttm is present", () => {
+ it("produces 2 segments when only queued_dttm is present", () => {
const result = transformGanttData({
allTries: [
{
end_date: "2024-03-14T10:05:00+00:00",
is_mapped: false,
queued_dttm: "2024-03-14T09:59:00+00:00",
- // eslint-disable-next-line unicorn/no-null
scheduled_dttm: null,
start_date: "2024-03-14T10:00:00+00:00",
state: "success",
@@ -323,15 +265,13 @@ describe("transformGanttData", () => {
expect(result[1]?.state).toBe("success");
});
- it("should produce 1 segment when scheduled_dttm and queued_dttm are null",
() => {
+ it("produces 1 segment when scheduled_dttm and queued_dttm are null", () => {
const result = transformGanttData({
allTries: [
{
end_date: "2024-03-14T10:05:00+00:00",
is_mapped: false,
- // eslint-disable-next-line unicorn/no-null
queued_dttm: null,
- // eslint-disable-next-line unicorn/no-null
scheduled_dttm: null,
start_date: "2024-03-14T10:00:00+00:00",
state: "success",
@@ -347,4 +287,39 @@ describe("transformGanttData", () => {
expect(result).toHaveLength(1);
expect(result[0]?.state).toBe("success");
});
+
+ it("sorts multiple tries by try_number", () => {
+ const result = transformGanttData({
+ allTries: [
+ {
+ end_date: "2024-03-14T10:05:00+00:00",
+ is_mapped: false,
+ queued_dttm: null,
+ scheduled_dttm: null,
+ start_date: "2024-03-14T10:00:00+00:00",
+ state: "failed",
+ task_display_name: "task_1",
+ task_id: "task_1",
+ try_number: 2,
+ },
+ {
+ end_date: "2024-03-14T09:55:00+00:00",
+ is_mapped: false,
+ queued_dttm: null,
+ scheduled_dttm: null,
+ start_date: "2024-03-14T09:50:00+00:00",
+ state: "failed",
+ task_display_name: "task_1",
+ task_id: "task_1",
+ try_number: 1,
+ },
+ ],
+ flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1"
}],
+ gridSummaries: [],
+ });
+
+ expect(result).toHaveLength(2);
+ expect(result[0]?.tryNumber).toBe(1);
+ expect(result[1]?.tryNumber).toBe(2);
+ });
});
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 621f22e8ab9..d4cba954b62 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,54 +16,47 @@
* specific language governing permissions and limitations
* under the License.
*/
-import type { ChartEvent, ActiveElement, TooltipItem } from "chart.js";
+
+/* eslint-disable max-lines -- Gantt transform, time range, links, and axis
ticks share one module */
import dayjs from "dayjs";
-import type { TFunction } from "i18next";
-import type { NavigateFunction, Location } from "react-router-dom";
-
-import type {
- GanttTaskInstance,
- GridRunsResponse,
- LightGridTaskInstanceSummary,
- TaskInstanceState,
-} from "openapi/requests";
+import type { To } from "react-router-dom";
+
+import type { GridRunsResponse, LightGridTaskInstanceSummary,
TaskInstanceState } from "openapi/requests";
+import type { GanttTaskInstance } from "openapi/requests/types.gen";
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 { renderDuration } from "src/utils/datetimeUtils";
import { buildTaskInstanceUrl } from "src/utils/links";
export type GanttDataItem = {
isGroup?: boolean | null;
isMapped?: boolean | null;
+ /** Source try times for tooltips (matches TaskInstance `*_when` fields). */
+ queued_when?: string | null;
+ scheduled_when?: string | null;
state?: TaskInstanceState | null;
taskId: string;
tryNumber?: number;
- x: Array<string>;
+ /** [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<GanttDataItem>;
- location: Location;
- navigate: NavigateFunction;
+ item: GanttDataItem;
+ /** Precomputed map from taskId → max try number; build once with
`buildMaxTryByTaskId`. */
+ maxTryByTaskId: Map<string, number>;
+ /** `location.pathname` — hoisted out of the segment loop so it is read once
per render. */
+ pathname: string;
runId: string;
-};
-
-type ChartOptionsParams = {
- data: Array<GanttDataItem>;
- gridColor?: string;
- handleBarClick: (event: ChartEvent, elements: Array<ActiveElement>) => void;
- handleBarHover: (event: ChartEvent, elements: Array<ActiveElement>) => void;
- hoveredId?: string | null;
- hoveredItemColor?: string;
- labels: Array<string>;
- selectedId?: string;
- selectedItemColor?: string;
- selectedRun?: GridRunsResponse;
- selectedTimezone: string;
- translate: TFunction;
+ /**
+ * Pre-parsed copy of `location.search` — pass `new
URLSearchParams(location.search)` built
+ * once per render. `getGanttSegmentTo` clones it internally before
mutating, so the caller's
+ * instance is never modified.
+ */
+ searchParams: URLSearchParams;
};
type TransformGanttDataParams = {
@@ -74,12 +65,24 @@ type TransformGanttDataParams = {
gridSummaries: Array<LightGridTaskInstanceSummary>;
};
+export const gridSummariesToTaskIdMap = (
+ summaries: Array<LightGridTaskInstanceSummary>,
+): Map<string, LightGridTaskInstanceSummary> => {
+ const byId = new Map<string, LightGridTaskInstanceSummary>();
+
+ for (const summary of summaries) {
+ byId.set(summary.task_id, summary);
+ }
+
+ return byId;
+};
+
export const transformGanttData = ({
allTries,
flatNodes,
gridSummaries,
}: TransformGanttDataParams): Array<GanttDataItem> => {
- // Group tries by task_id
+ // Pre-index both lookups as Maps to keep the overall transform O(n+m).
const triesByTask = new Map<string, Array<GanttTaskInstance>>();
for (const ti of allTries) {
@@ -89,12 +92,13 @@ export const transformGanttData = ({
triesByTask.set(ti.task_id, existing);
}
+ const summaryByTaskId = gridSummariesToTaskIdMap(gridSummaries);
+
return flatNodes
.flatMap((node): Array<GanttDataItem> | 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,74 +110,100 @@ 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);
if (tries && tries.length > 0) {
- return tries
- .filter((tryInstance) => tryInstance.start_date !== null)
- .flatMap((tryInstance) => {
- const hasTaskRunning = isStatePending(tryInstance.state);
- const endTime =
- hasTaskRunning || tryInstance.end_date === null
- ? dayjs().toISOString()
- : tryInstance.end_date;
- const items: Array<GanttDataItem> = [];
-
- // Scheduled segment: from scheduled_dttm to queued_dttm (or
start_date if no queued_dttm)
- if (tryInstance.scheduled_dttm !== null) {
- const scheduledEnd = tryInstance.queued_dttm ??
tryInstance.start_date ?? undefined;
+ const sortedTries = [...tries].sort(
+ (leftTry, rightTry) => leftTry.try_number - rightTry.try_number,
+ );
+
+ return sortedTries.flatMap((tryRow: GanttTaskInstance):
Array<GanttDataItem> => {
+ const items: Array<GanttDataItem> = [];
+ const hasTaskRunning = isStatePending(tryRow.state);
+ const startDate: string | null = tryRow.start_date;
+ const endDate: string | null = tryRow.end_date;
+ const queuedDttm = tryRow.queued_dttm;
+ const scheduledDttm = tryRow.scheduled_dttm;
+ const startMs = startDate === null ? undefined :
dayjs(startDate).valueOf();
+ const queuedMs = queuedDttm === null ? undefined :
dayjs(queuedDttm).valueOf();
+ const scheduledMs = scheduledDttm === null ? undefined :
dayjs(scheduledDttm).valueOf();
+
+ // Include scheduled/queued times in tooltip data whenever the
timestamps exist.
+ const tryWhenForTooltip = {
+ ...(scheduledMs === undefined ? {} : { scheduled_when:
scheduledDttm }),
+ ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }),
+ };
+
+ let endMs: number;
+
+ if (hasTaskRunning) {
+ endMs = Date.now();
+ } else if (endDate === null) {
+ endMs = startMs ?? Date.now();
+ } else {
+ endMs = dayjs(endDate).valueOf();
+ }
+ if (scheduledMs !== undefined) {
+ const scheduledEndMs =
+ queuedMs ?? startMs ?? (hasTaskRunning || tryRow.state ===
"scheduled" ? Date.now() : endMs);
+
+ if (scheduledEndMs > scheduledMs) {
items.push({
isGroup: false,
- isMapped: tryInstance.is_mapped,
- state: "scheduled" as TaskInstanceState,
- taskId: tryInstance.task_id,
- tryNumber: tryInstance.try_number,
- x: [dayjs(tryInstance.scheduled_dttm).toISOString(),
dayjs(scheduledEnd).toISOString()],
- y: tryInstance.task_display_name,
+ isMapped: tryRow.is_mapped,
+ state: "scheduled",
+ taskId: tryRow.task_id,
+ tryNumber: tryRow.try_number,
+ ...tryWhenForTooltip,
+ x: [scheduledMs, scheduledEndMs],
+ y: tryRow.task_display_name,
});
}
+ }
- // Queue segment: from queued_dttm to start_date
- if (tryInstance.queued_dttm !== null) {
+ if (queuedMs !== undefined) {
+ const queueEndMs = startMs ?? (hasTaskRunning ? Date.now() :
endMs);
+
+ if (queueEndMs > queuedMs) {
items.push({
isGroup: false,
- isMapped: tryInstance.is_mapped,
- state: "queued" as TaskInstanceState,
- taskId: tryInstance.task_id,
- tryNumber: tryInstance.try_number,
- x: [
- dayjs(tryInstance.queued_dttm).toISOString(),
- dayjs(tryInstance.start_date ?? undefined).toISOString(),
- ],
- y: tryInstance.task_display_name,
+ isMapped: tryRow.is_mapped,
+ state: "queued",
+ taskId: tryRow.task_id,
+ tryNumber: tryRow.try_number,
+ ...tryWhenForTooltip,
+ x: [queuedMs, queueEndMs],
+ y: tryRow.task_display_name,
});
}
+ }
+
+ // Execution segment: start_date → end
+ if (startMs !== undefined) {
+ const execEndMs = Math.max(startMs, endMs);
- // Execution segment: from start_date to end_date
items.push({
isGroup: false,
- isMapped: tryInstance.is_mapped,
- state: tryInstance.state,
- taskId: tryInstance.task_id,
- tryNumber: tryInstance.try_number,
- x: [dayjs(tryInstance.start_date).toISOString(),
dayjs(endTime).toISOString()],
- y: tryInstance.task_display_name,
+ isMapped: tryRow.is_mapped,
+ state: tryRow.state,
+ taskId: tryRow.task_id,
+ tryNumber: tryRow.try_number,
+ ...tryWhenForTooltip,
+ x: [startMs, execEndMs],
+ y: tryRow.task_display_name,
});
+ }
- return items;
- });
+ return items;
+ });
}
}
@@ -182,251 +212,169 @@ export const transformGanttData = ({
.filter((item): item is GanttDataItem => item !== undefined);
};
-export const createHandleBarClick =
- ({ dagId, data, location, navigate, runId }: HandleBarClickOptions) =>
- (_: ChartEvent, elements: Array<ActiveElement>) => {
- if (elements.length === 0 || !elements[0] || !runId) {
- return;
- }
+/** One entry per flat node: segments to draw in that row (tries or
aggregate). */
+export const buildGanttRowSegments = (
+ flatNodes: Array<GridTask>,
+ items: Array<GanttDataItem>,
+): Array<Array<GanttDataItem>> => {
+ const byTaskId = new Map<string, Array<GanttDataItem>>();
- const clickedData = data[elements[0].index];
+ for (const item of items) {
+ const list = byTaskId.get(item.taskId) ?? [];
- if (!clickedData) {
- return;
- }
+ list.push(item);
+ byTaskId.set(item.taskId, list);
+ }
- const { isGroup, isMapped, taskId, tryNumber } = clickedData;
+ return flatNodes.map((node) => byTaskId.get(node.id) ?? []);
+};
- const taskUrl = buildTaskInstanceUrl({
- currentPathname: location.pathname,
- dagId,
- isGroup: Boolean(isGroup),
- isMapped: Boolean(isMapped),
- runId,
- taskId,
- });
+export const computeGanttTimeRangeMs = ({
+ ganttItems,
+ selectedRun,
+ selectedTimezone,
+}: {
+ ganttItems: Array<GanttDataItem>;
+ selectedRun?: GridRunsResponse;
+ selectedTimezone: string;
+}): { maxMs: number; minMs: number } => {
+ const isActivePending = selectedRun !== undefined &&
isStatePending(selectedRun.state);
+ // Compute the effective end timestamp directly in milliseconds to avoid the
+ // string-format → new Date() round-trip which is browser-inconsistent.
+ const effectiveEndMs = isActivePending
+ ? dayjs().tz(selectedTimezone).valueOf()
+ : selectedRun?.end_date !== null && selectedRun?.end_date !== undefined
+ ? dayjs(selectedRun.end_date).valueOf()
+ : Date.now();
+
+ if (ganttItems.length === 0) {
+ const minMs =
+ selectedRun?.start_date !== null && selectedRun?.start_date !== undefined
+ ? dayjs(selectedRun.start_date).valueOf()
+ : Date.now();
+
+ return { maxMs: effectiveEndMs, minMs };
+ }
- const searchParams = new URLSearchParams(location.search);
- const isOlderTry =
- tryNumber !== undefined &&
- tryNumber <
- Math.max(...data.filter((item) => item.taskId === taskId).map((item)
=> item.tryNumber ?? 1));
+ let minTime = Infinity;
+ let maxTime = -Infinity;
- if (isOlderTry) {
- searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString());
- } else {
- searchParams.delete(SearchParamsKeys.TRY_NUMBER);
+ for (const item of ganttItems) {
+ if (item.x[0] < minTime) {
+ [minTime] = item.x;
+ }
+ if (item.x[1] > maxTime) {
+ [, maxTime] = item.x;
}
+ }
+
+ const totalDuration = maxTime - minTime;
- void Promise.resolve(
- navigate(
- {
- pathname: taskUrl,
- search: searchParams.toString(),
- },
- { replace: true },
- ),
- );
+ return {
+ maxMs: maxTime + totalDuration * 0.05,
+ minMs: minTime - totalDuration * 0.02,
};
+};
-export const createHandleBarHover = (
- data: Array<GanttDataItem>,
- setHoveredTaskId: (taskId: string | undefined) => void,
-) => {
- let lastHoveredTaskId: string | undefined = undefined;
-
- return (_: ChartEvent, elements: Array<ActiveElement>) => {
- // Clear previous hover styles
- if (lastHoveredTaskId !== undefined) {
- const previousTasks = document.querySelectorAll<HTMLDivElement>(
- `#${lastHoveredTaskId.replaceAll(".", "-")}`,
- );
-
- previousTasks.forEach((task) => {
- task.style.backgroundColor = "";
- });
- }
+/**
+ * Precompute the maximum try number for each task in O(n).
+ * Pass the result to `getGanttSegmentTo` to avoid an O(n) scan per segment.
+ */
+export const buildMaxTryByTaskId = (ganttItems: Array<GanttDataItem>):
Map<string, number> => {
+ const map = new Map<string, number>();
+
+ for (const item of ganttItems) {
+ const current = map.get(item.taskId) ?? 0;
+
+ map.set(item.taskId, Math.max(current, item.tryNumber ?? 1));
+ }
- if (elements.length > 0 && elements[0] && elements[0].index < data.length)
{
- const hoveredData = data[elements[0].index];
+ return map;
+};
- if (hoveredData?.taskId !== undefined) {
- lastHoveredTaskId = hoveredData.taskId;
- setHoveredTaskId(hoveredData.taskId);
+export const getGanttSegmentTo = ({
+ dagId,
+ item,
+ maxTryByTaskId,
+ pathname,
+ runId,
+ searchParams: baseSearchParams,
+}: GanttSegmentLinkParams): To | undefined => {
+ if (!runId) {
+ return undefined;
+ }
- // Apply new hover styles
- const tasks = document.querySelectorAll<HTMLDivElement>(
- `#${hoveredData.taskId.replaceAll(".", "-")}`,
- );
+ const { isGroup, isMapped, taskId, tryNumber } = item;
+
+ const segmentPathname = buildTaskInstanceUrl({
+ currentPathname: pathname,
+ dagId,
+ isGroup: Boolean(isGroup),
+ isMapped: Boolean(isMapped),
+ runId,
+ taskId,
+ });
+
+ // Clone the pre-parsed params so mutations don't leak across segments.
+ const searchParams = new URLSearchParams(baseSearchParams);
+ const maxTryForTask = maxTryByTaskId.get(taskId) ?? 1;
+ const isOlderTry = tryNumber !== undefined && tryNumber < maxTryForTask;
+
+ if (isOlderTry) {
+ searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString());
+ } else {
+ searchParams.delete(SearchParamsKeys.TRY_NUMBER);
+ }
- tasks.forEach((task) => {
- task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
- });
- }
- } else {
- lastHoveredTaskId = undefined;
- setHoveredTaskId(undefined);
- }
+ return {
+ pathname: segmentPathname,
+ search: searchParams.toString(),
};
};
-export const createChartOptions = ({
- data,
- gridColor,
- handleBarClick,
- handleBarHover,
- hoveredId,
- hoveredItemColor,
- labels,
- selectedId,
- selectedItemColor,
- selectedRun,
- selectedTimezone,
- translate,
-}: ChartOptionsParams) => {
- const isActivePending = isStatePending(selectedRun?.state);
- const effectiveEndDate = isActivePending
- ? dayjs().tz(selectedTimezone).format("YYYY-MM-DD HH:mm:ss")
- : selectedRun?.end_date;
+/** Default number of time labels along the Gantt axis (endpoints included). */
+export const GANTT_TIME_AXIS_TICK_COUNT = 8;
- return {
- animation: {
- duration: 150,
- easing: "linear" as const,
- },
- datasets: {
- bar: {
- minBarLength: 4,
- },
- },
- indexAxis: "y" as const,
- maintainAspectRatio: false,
- onClick: handleBarClick,
- onHover: (event: ChartEvent, elements: Array<ActiveElement>) => {
- const target = event.native?.target as HTMLElement | undefined;
-
- if (target) {
- target.style.cursor = elements.length > 0 ? "pointer" : "default";
- }
+export type GanttAxisTickLabelAlign = "center" | "left" | "right";
- 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<TooltipItem<"bar">>) {
- 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}`);
- }
+export type GanttAxisTick = {
+ label: string;
+ labelAlign: GanttAxisTickLabelAlign;
+ leftPct: number;
+};
- return lines;
- },
- label(tooltipItem: TooltipItem<"bar">) {
- const taskInstance = data[tooltipItem.dataIndex];
+/** 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);
- return `${translate("state")}:
${translate(`common: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) =>
dayjs(item.x[1]).valueOf()));
- const minTime = Math.min(...data.map((item) =>
dayjs(item.x[0]).valueOf()));
- 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) =>
dayjs(item.x[1]).valueOf()));
- const minTime = Math.min(...data.map((item) =>
dayjs(item.x[0]).valueOf()));
- 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,
- },
- },
- },
- };
+ 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<GanttAxisTick> => {
+ const spanMs = Math.max(1, maxMs - minMs);
+ const denominator = Math.max(1, tickCount - 1);
+ const lastIndex = tickCount - 1;
+ const ticks: Array<GanttAxisTick> = [];
+
+ 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 0c798ab843a..17ec5cdb0a0 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) => (
- <Box borderBottomWidth={1} left={0} position="absolute" right={0} zIndex={0}
{...props} />
+ <Box
+ borderBottomWidth={1}
+ borderColor="border"
+ left={0}
+ position="absolute"
+ right={0}
+ zIndex={0}
+ {...props}
+ />
);
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 dbe30598010..9052144f470 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<HTMLDivElement | null>;
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<HTMLDivElement>(null);
- const scrollContainerRef = useRef<HTMLDivElement>(null);
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
+
+ const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt);
const [selectedIsVisible, setSelectedIsVisible] = useState<boolean |
undefined>();
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 */}
+ <Flex bg="bg" display="flex" position="sticky"
pt={`${GRID_HEADER_PADDING_PX}px`} top={0} zIndex={2}>
+ <Box bg="bg" left={0} position="sticky" zIndex={1}
{...taskNameColumnStyles}>
+ <Flex flexDirection="column-reverse"
height={`${GRID_HEADER_HEIGHT_PX}px`} position="relative">
+ {Boolean(gridRuns?.length) && (
+ <>
+ <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX - 8}px`}
duration={max} />
+ <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX / 2 - 4}px`}
duration={max / 2} />
+ </>
+ )}
+ </Flex>
+ </Box>
+ {/* Duration bars */}
+ <Flex flexDirection="row-reverse" flexShrink={0}>
+ <Flex flexShrink={0} position="relative">
+ <DurationAxis top={`${GRID_HEADER_HEIGHT_PX}px`} />
+ <DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
+ <DurationAxis top="4px" />
+ <Flex flexDirection="row-reverse">
+ {runsWithVersionFlags?.map((dr) => (
+ <Bar
+ key={dr.run_id}
+ max={max}
+ onClick={handleColumnClick}
+ run={dr}
+ showVersionIndicatorMode={showVersionIndicatorMode}
+ />
+ ))}
+ </Flex>
+ {selectedIsVisible === undefined || !selectedIsVisible ? undefined
: (
+ <Link to={`/dags/${dagId}`}>
+ <IconButton
+ aria-label={translate("grid.buttons.resetToLatest")}
+ height={`${GRID_HEADER_HEIGHT_PX - 2}px`}
+ loading={isLoading}
+ minW={0}
+ ml={1}
+ title={translate("grid.buttons.resetToLatest")}
+ variant="surface"
+ zIndex={1}
+ >
+ <FiChevronsRight />
+ </IconButton>
+ </Link>
+ )}
+ </Flex>
+ </Flex>
+ </Flex>
+
+ {/* Grid body */}
+ <Flex height={`${rowVirtualizer.getTotalSize()}px`} position="relative">
+ <Box bg="bg" left={0} position="sticky" zIndex={1}
{...taskNameColumnStyles}>
+ <TaskNames nodes={flatNodes} onRowClick={handleRowClick}
virtualItems={virtualItems} />
+ </Box>
+ <Flex flexDirection="row-reverse" flexShrink={0}>
+ {gridRuns?.map((dr: GridRunsResponse) => (
+ <TaskInstancesColumn
+ key={dr.run_id}
+ nodes={flatNodes}
+ onCellClick={handleCellClick}
+ run={dr}
+ showVersionIndicatorMode={showVersionIndicatorMode}
+ tiSummaries={summariesByRunId.get(dr.run_id)}
+ virtualItems={virtualItems}
+ />
+ ))}
+ </Flex>
+ </Flex>
+ </>
+ );
+
return (
<Flex
flexDirection="column"
+ flexGrow={showGantt ? 0 : 1}
+ flexShrink={showGantt ? 0 : undefined}
+ height={showGantt ? undefined : "100%"}
justifyContent="flex-start"
position="relative"
- pt={`${GRID_OUTER_PADDING_PX}px`}
ref={gridRef}
tabIndex={0}
- width={showGantt ? "1/2" : "full"}
+ w={showGantt ? "fit-content" : "full"}
>
- {/* Grid scroll container */}
- <Box
- height="calc(100vh - 140px)"
- marginRight={showGantt ? 0 : 1}
- overflow="auto"
- paddingRight={showGantt ? 0 : 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={`${GRID_HEADER_PADDING_PX}px`} top={0} zIndex={2}>
- <Box bg="bg" flexGrow={1} left={0} minWidth="200px"
position="sticky" zIndex={1}>
- <Flex flexDirection="column-reverse"
height={`${GRID_HEADER_HEIGHT_PX}px`} position="relative">
- {Boolean(gridRuns?.length) && (
- <>
- <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX - 8}px`}
duration={max} />
- <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX / 2 - 4}px`}
duration={max / 2} />
- </>
- )}
- </Flex>
- </Box>
- {/* Duration bars */}
- <Flex flexDirection="row-reverse" flexShrink={0}>
- <Flex flexShrink={0} position="relative">
- <DurationAxis top={`${GRID_HEADER_HEIGHT_PX}px`} />
- <DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
- <DurationAxis top="4px" />
- <Flex flexDirection="row-reverse">
- {runsWithVersionFlags?.map((dr) => (
- <Bar
- key={dr.run_id}
- max={max}
- onClick={handleColumnClick}
- run={dr}
- showVersionIndicatorMode={showVersionIndicatorMode}
- />
- ))}
- </Flex>
- {selectedIsVisible === undefined || !selectedIsVisible ?
undefined : (
- <Link to={`/dags/${dagId}`}>
- <IconButton
- aria-label={translate("grid.buttons.resetToLatest")}
- height={`${GRID_HEADER_HEIGHT_PX - 2}px`}
- loading={isLoading}
- minW={0}
- ml={1}
- title={translate("grid.buttons.resetToLatest")}
- variant="surface"
- zIndex={1}
- >
- <FiChevronsRight />
- </IconButton>
- </Link>
- )}
- </Flex>
- </Flex>
- </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) => (
- <TaskInstancesColumn
- key={dr.run_id}
- nodes={flatNodes}
- onCellClick={handleCellClick}
- run={dr}
- showVersionIndicatorMode={showVersionIndicatorMode}
- tiSummaries={summariesByRunId.get(dr.run_id)}
- virtualItems={virtualItems}
- />
- ))}
- </Flex>
- </Flex>
- </Box>
+ {usesSharedScroll ? (
+ gridHeaderAndBody
+ ) : (
+ <Box
+ flex={1}
+ marginRight={showGantt ? 0 : 1}
+ minH={0}
+ overflow="auto"
+ paddingRight={showGantt ? 0 : 4}
+ position="relative"
+ ref={scrollContainerRef}
+ >
+ {gridHeaderAndBody}
+ </Box>
+ )}
</Flex>
);
};
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 05b32145514..ed47572760c 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<VirtualItem>;
};
-const ROW_HEIGHT = 20;
+type CellBorderProps = Pick<BoxProps, "borderBottomWidth" | "borderColor" |
"borderTopWidth">;
+
+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 (
<Box
+ {...taskInstanceCellBorderProps(hideRowBorders,
virtualItem.index)}
height={`${ROW_HEIGHT}px`}
key={`${node.id}-${run.run_id}`}
left={0}
@@ -124,6 +136,8 @@ export const TaskInstancesColumn = ({
return (
<Box
+ {...taskInstanceCellBorderProps(hideRowBorders, virtualItem.index)}
+ height={`${ROW_HEIGHT}px`}
key={node.id}
left={0}
position="absolute"
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 510d8be7143..8b9910cf0b5 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
@@ -27,6 +27,7 @@ import { TaskName } from "src/components/TaskName";
import { useHover } from "src/context/hover";
import { useOpenGroups } from "src/context/openGroups";
+import { ROW_HEIGHT } from "./constants";
import type { GridTask } from "./utils";
type Props = {
@@ -36,8 +37,6 @@ type Props = {
readonly virtualItems?: Array<VirtualItem>;
};
-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 99bd035896d..616dd2223d9 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 e60012e58ba..b4b3d923e33 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<GridRunWithVersionFlags> |
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 00000000000..b034a8f3100
--- /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<GridTask> = [{ ...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<GridTask> = [{ ...baseNode, depth: 0, id: "a", label:
"x".repeat(40) } as GridTask];
+ const deep: Array<GridTask> = [{ ...baseNode, depth: 4, id: "b", label:
"x".repeat(40) } as GridTask];
+ const group: Array<GridTask> = [
+ { ...baseNode, depth: 0, id: "c", isGroup: true, label: "x".repeat(40) }
as GridTask,
+ ];
+ const mapped: Array<GridTask> = [
+ { ...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 019039a8401..242004a9120 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<GridTask>): 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<GridNodeResponse> | undefined,
openGroupIds: Array<string>,
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 860f9a4d200..2e154fd3824 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,7 @@ 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 type { DagView } from "src/constants/dagView";
import { dependenciesKey, directionKey } from "src/constants/localStorage";
import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
import { dagRunTypeOptions, dagRunStateOptions } from
"src/constants/stateOptions";
@@ -67,22 +67,20 @@ import { VersionIndicatorSelect } from
"./VersionIndicatorSelect";
type Props = {
readonly dagRunStateFilter: DagRunState | undefined;
- readonly dagView: "graph" | "grid";
+ readonly dagView: DagView;
readonly limit: number;
readonly panelGroupRef: React.RefObject<ImperativePanelGroupHandle | null>;
readonly runAfterGte: string | undefined;
readonly runAfterLte: string | undefined;
readonly runTypeFilter: DagRunType | undefined;
readonly setDagRunStateFilter:
React.Dispatch<React.SetStateAction<DagRunState | undefined>>;
- readonly setDagView: (x: "graph" | "grid") => void;
+ readonly setDagView: (view: DagView) => void;
readonly setLimit: React.Dispatch<React.SetStateAction<number>>;
readonly setRunAfterGte: React.Dispatch<React.SetStateAction<string |
undefined>>;
readonly setRunAfterLte: React.Dispatch<React.SetStateAction<string |
undefined>>;
readonly setRunTypeFilter: React.Dispatch<React.SetStateAction<DagRunType |
undefined>>;
- readonly setShowGantt: React.Dispatch<React.SetStateAction<boolean>>;
readonly setShowVersionIndicatorMode:
React.Dispatch<React.SetStateAction<VersionIndicatorOptions>>;
readonly setTriggeringUserFilter: React.Dispatch<React.SetStateAction<string
| undefined>>;
- readonly showGantt: boolean;
readonly showVersionIndicatorMode: VersionIndicatorOptions;
readonly triggeringUserFilter: string | undefined;
};
@@ -134,10 +132,8 @@ export const PanelButtons = ({
setRunAfterGte,
setRunAfterLte,
setRunTypeFilter,
- setShowGantt,
setShowVersionIndicatorMode,
setTriggeringUserFilter,
- showGantt,
showVersionIndicatorMode,
triggeringUserFilter,
}: Props) => {
@@ -158,7 +154,7 @@ export const PanelButtons = ({
setLimit(runLimit);
};
- const enableResponsiveOptions = showGantt && Boolean(runId);
+ const enableResponsiveOptions = dagView === "gantt";
const { displayRunOptions, limit: defaultLimit } = getWidthBasedConfig(
containerWidth,
@@ -249,24 +245,30 @@ export const PanelButtons = ({
}
};
- const dagViewOptions: Array<ButtonGroupOption<"graph" | "grid">> = useMemo(
- () => [
- {
- dataTestId: "grid-view-button",
- label: <FiGrid />,
- title: translate("dag:panel.buttons.showGridShortcut"),
- value: "grid",
- },
- {
- label: <MdOutlineAccountTree />,
- title: translate("dag:panel.buttons.showGraphShortcut"),
- value: "graph",
- },
- ],
- [translate],
- );
+ const dagViewOptions: Array<ButtonGroupOption<DagView>> = [
+ {
+ dataTestId: "grid-view-button",
+ label: <FiGrid />,
+ title: translate("dag:panel.buttons.showGridShortcut"),
+ value: "grid",
+ },
+ ...(shouldShowToggleButtons
+ ? [
+ {
+ label: <LuChartGantt />,
+ title: translate("dag:panel.buttons.showGantt"),
+ value: "gantt" as const,
+ },
+ ]
+ : []),
+ {
+ label: <MdOutlineAccountTree />,
+ 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 +289,7 @@ export const PanelButtons = ({
);
return (
- <Box position="absolute" pr={4} ref={containerRef} top={1} width="100%"
zIndex={1}>
+ <Box bg="bg" pr={4} ref={containerRef} width="100%" zIndex={1}>
<Flex justifyContent="space-between">
<ButtonGroupToggle isIcon onChange={handleDagViewChange}
options={dagViewOptions} value={dagView} />
<Flex alignItems="center" gap={1} justifyContent="space-between">
@@ -537,13 +539,6 @@ export const PanelButtons = ({
value={runAfterRange}
/>
</VStack>
- {shouldShowToggleButtons ? (
- <VStack alignItems="flex-start" px={1}>
- <Checkbox checked={showGantt} onChange={() =>
setShowGantt(!showGantt)} size="sm">
- {translate("dag:panel.buttons.showGantt")}
- </Checkbox>
- </VStack>
- ) : undefined}
<VStack alignItems="flex-start" px={1}>
<VersionIndicatorSelect
onChange={setShowVersionIndicatorMode}
@@ -560,7 +555,7 @@ export const PanelButtons = ({
</Flex>
</Flex>
- {dagView === "grid" && (
+ {dagView !== "graph" && (
<Flex color="fg.muted" gap={2} justifyContent="flex-end" mt={1}>
<RunTypeLegend />
<Tooltip
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
index 5e7b66c7a5e..8750cb850bd 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
@@ -152,6 +152,18 @@ export const Details = () => {
<Table.Cell>{translate("duration")}</Table.Cell>
<Table.Cell>{renderDuration(tryInstance?.duration)}</Table.Cell>
</Table.Row>
+ <Table.Row>
+ <Table.Cell>{translate("taskInstance.scheduledWhen")}</Table.Cell>
+ <Table.Cell>
+ <Time datetime={tryInstance?.scheduled_when} />
+ </Table.Cell>
+ </Table.Row>
+ <Table.Row>
+ <Table.Cell>{translate("taskInstance.queuedWhen")}</Table.Cell>
+ <Table.Cell>
+ <Time datetime={tryInstance?.queued_when} />
+ </Table.Cell>
+ </Table.Row>
<Table.Row>
<Table.Cell>{translate("startDate")}</Table.Cell>
<Table.Cell>