Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions airflow-core/src/airflow/ui/src/context/hover/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
import { createContext } from "react";

export type HoverContextType = {
hoveredRunId: string | undefined;
hoveredTaskId: string | undefined;
setHoveredRunId: (runId: string | undefined) => void;
setHoveredTaskId: (taskId: string | undefined) => void;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@ import { useState, useMemo } from "react";
import { HoverContext } from "./Context";

export const HoverProvider = ({ children }: PropsWithChildren) => {
const [hoveredRunId, setHoveredRunId] = useState<string | undefined>(undefined);
const [hoveredTaskId, setHoveredTaskId] = useState<string | undefined>(undefined);

const value = useMemo(
() => ({
hoveredRunId,
hoveredTaskId,
setHoveredRunId,
setHoveredTaskId,
}),
[hoveredTaskId],
[hoveredRunId, hoveredTaskId],
);

return <HoverContext.Provider value={value}>{children}</HoverContext.Provider>;
Expand Down
2 changes: 2 additions & 0 deletions airflow-core/src/airflow/ui/src/hooks/navigation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
export { useNavigation } from "./useNavigation";
export { useKeyboardNavigation } from "./useKeyboardNavigation";

export { NavigationModes } from "./types";

export type {
ArrowKey,
NavigationDirection,
Expand Down
8 changes: 7 additions & 1 deletion airflow-core/src/airflow/ui/src/hooks/navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@
import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";

export type NavigationMode = "run" | "task" | "TI";
export const NavigationModes = {
RUN: "run",
TASK: "task",
TI: "TI",
} as const;

export type NavigationMode = (typeof NavigationModes)[keyof typeof NavigationModes];

export type ArrowKey = "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp";

Expand Down
35 changes: 18 additions & 17 deletions airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,37 @@ import type { GridRunsResponse } from "openapi/requests";
import type { GridTask } from "src/layouts/Details/Grid/utils";
import { buildTaskInstanceUrl } from "src/utils/links";

import type {
NavigationDirection,
NavigationIndices,
NavigationMode,
UseNavigationProps,
UseNavigationReturn,
import {
NavigationModes,
type NavigationDirection,
type NavigationIndices,
type NavigationMode,
type UseNavigationProps,
type UseNavigationReturn,
} from "./types";
import { useKeyboardNavigation } from "./useKeyboardNavigation";

const detectModeFromUrl = (pathname: string): NavigationMode => {
if (pathname.includes("/runs/") && pathname.includes("/tasks/")) {
return "TI";
return NavigationModes.TI;
}
if (pathname.includes("/runs/") && !pathname.includes("/tasks/")) {
return "run";
return NavigationModes.RUN;
}
if (pathname.includes("/tasks/") && !pathname.includes("/runs/")) {
return "task";
return NavigationModes.TASK;
}

return "TI";
return NavigationModes.TI;
};

const isValidDirection = (direction: NavigationDirection, mode: NavigationMode): boolean => {
switch (mode) {
case "run":
case NavigationModes.RUN:
return direction === "left" || direction === "right";
case "task":
case NavigationModes.TASK:
return direction === "down" || direction === "up";
case "TI":
case NavigationModes.TI:
return true;
default:
return false;
Expand All @@ -74,11 +75,11 @@ const buildPath = (params: {
const groupPath = task.isGroup ? "group/" : "";

switch (mode) {
case "run":
case NavigationModes.RUN:
return `/dags/${dagId}/runs/${run.run_id}`;
case "task":
case NavigationModes.TASK:
return `/dags/${dagId}/tasks/${groupPath}${task.id}`;
case "TI":
case NavigationModes.TI:
return buildTaskInstanceUrl({
currentPathname: pathname,
dagId,
Expand All @@ -98,7 +99,7 @@ export const useNavigation = ({ onToggleGroup, runs, tasks }: UseNavigationProps
const enabled = Boolean(dagId) && (Boolean(runId) || Boolean(taskId) || Boolean(groupId));
const navigate = useNavigate();
const location = useLocation();
const [mode, setMode] = useState<NavigationMode>("TI");
const [mode, setMode] = useState<NavigationMode>(NavigationModes.TI);

useEffect(() => {
const detectedMode = detectModeFromUrl(globalThis.location.pathname);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => {
minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35 : 6}
order={1}
>
<Box height="100%" marginInlineEnd={2} overflowY="auto" paddingRight={4} position="relative">
<Box height="100%" position="relative">
<PanelButtons
dagView={dagView}
limit={limit}
Expand Down
31 changes: 13 additions & 18 deletions airflow-core/src/airflow/ui/src/layouts/Details/Grid/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,47 +17,48 @@
* under the License.
*/
import { Flex, Box } from "@chakra-ui/react";
import { useCallback } from "react";
import { useParams, useSearchParams } from "react-router-dom";

import type { GridRunsResponse } from "openapi/requests";
import { RunTypeIcon } from "src/components/RunTypeIcon";
import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts";
import { useHover } from "src/context/hover";

import { GridButton } from "./GridButton";
import { TaskInstancesColumn } from "./TaskInstancesColumn";
import type { GridTask } from "./utils";

const BAR_HEIGHT = 100;

type Props = {
readonly max: number;
readonly nodes: Array<GridTask>;
readonly onCellClick?: () => void;
readonly onColumnClick?: () => void;
readonly onClick?: () => void;
readonly run: GridRunsResponse;
};

export const Bar = ({ max, nodes, onCellClick, onColumnClick, run }: Props) => {
export const Bar = ({ max, onClick, run }: Props) => {
const { dagId = "", runId } = useParams();
const [searchParams] = useSearchParams();
const { hoveredRunId, setHoveredRunId } = useHover();

const isSelected = runId === run.run_id;

const isHovered = hoveredRunId === run.run_id;
const search = searchParams.toString();
const { data: gridTISummaries } = useGridTiSummaries({ dagId, runId: run.run_id, state: run.state });

const handleMouseEnter = useCallback(() => setHoveredRunId(run.run_id), [setHoveredRunId, run.run_id]);
const handleMouseLeave = useCallback(() => setHoveredRunId(undefined), [setHoveredRunId]);

return (
<Box
_hover={{ bg: "brand.subtle" }}
bg={isSelected ? "brand.muted" : undefined}
bg={isSelected ? "brand.emphasized" : isHovered ? "brand.muted" : undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
position="relative"
transition="background-color 0.2s"
>
<Flex
alignItems="flex-end"
height={BAR_HEIGHT}
justifyContent="center"
onClick={onColumnClick}
onClick={onClick}
pb="2px"
px="5px"
width="18px"
Expand All @@ -80,12 +81,6 @@ export const Bar = ({ max, nodes, onCellClick, onColumnClick, run }: Props) => {
{run.run_type !== "scheduled" && <RunTypeIcon color="white" runType={run.run_type} size="10px" />}
</GridButton>
</Flex>
<TaskInstancesColumn
nodes={nodes}
onCellClick={onCellClick}
runId={run.run_id}
taskInstances={gridTISummaries?.task_instances ?? []}
/>
</Box>
);
};
123 changes: 82 additions & 41 deletions airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,32 @@
* under the License.
*/
import { Box, Flex, IconButton } from "@chakra-ui/react";
import { useVirtualizer } from "@tanstack/react-virtual";
import dayjs from "dayjs";
import dayjsDuration from "dayjs/plugin/duration";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiChevronsRight } from "react-icons/fi";
import { Link, useParams } from "react-router-dom";

import type { DagRunType, GridRunsResponse } from "openapi/requests";
import { useOpenGroups } from "src/context/openGroups";
import { useNavigation } from "src/hooks/navigation";
import { NavigationModes, useNavigation } from "src/hooks/navigation";
import { useGridRuns } from "src/queries/useGridRuns.ts";
import { useGridStructure } from "src/queries/useGridStructure.ts";
import { isStatePending } from "src/utils";

import { Bar } from "./Bar";
import { DurationAxis } from "./DurationAxis";
import { DurationTick } from "./DurationTick";
import { TaskInstancesColumn } from "./TaskInstancesColumn";
import { TaskNames } from "./TaskNames";
import { flattenNodes } from "./utils";

dayjs.extend(dayjsDuration);

const ROW_HEIGHT = 20;

type Props = {
readonly limit: number;
readonly runType?: DagRunType | undefined;
Expand All @@ -49,6 +53,7 @@ type Props = {
export const Grid = ({ limit, runType, showGantt, triggeringUser }: Props) => {
const { t: translate } = useTranslation("dag");
const gridRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);

const [selectedIsVisible, setSelectedIsVisible] = useState<boolean | undefined>();
const { openGroupIds, toggleGroupId } = useOpenGroups();
Expand Down Expand Up @@ -93,61 +98,97 @@ export const Grid = ({ limit, runType, showGantt, triggeringUser }: Props) => {
tasks: flatNodes,
});

const handleRowClick = useCallback(() => setMode(NavigationModes.TASK), [setMode]);
const handleCellClick = useCallback(() => setMode(NavigationModes.TI), [setMode]);
const handleColumnClick = useCallback(() => setMode(NavigationModes.RUN), [setMode]);

const rowVirtualizer = useVirtualizer({
count: flatNodes.length,
estimateSize: () => ROW_HEIGHT,
getScrollElement: () => scrollContainerRef.current,
overscan: 5,
});

const virtualItems = rowVirtualizer.getVirtualItems();

return (
<Flex
flexDirection="column"
justifyContent="flex-start"
outline="none"
position="relative"
pt={20}
pt={16}
ref={gridRef}
tabIndex={0}
width={showGantt ? "1/2" : "full"}
>
<Box display="flex" flexDirection="column" flexGrow={1} justifyContent="end" minWidth="200px">
<TaskNames nodes={flatNodes} onRowClick={() => setMode("task")} />
</Box>
<Box position="relative">
<Flex position="relative">
<DurationAxis top="100px" />
<DurationAxis top="50px" />
<DurationAxis top="4px" />
<Flex flexDirection="column-reverse" height="100px" position="relative">
{Boolean(gridRuns?.length) && (
<>
<DurationTick bottom="92px" duration={max} />
<DurationTick bottom="46px" duration={max / 2} />
<DurationTick bottom="-4px" duration={0} />
</>
)}
{/* Grid scroll container */}
<Box
height="calc(100vh - 140px)"
marginRight={1}
overflow="auto"
paddingRight={4}
position="relative"
ref={scrollContainerRef}
>
{/* Grid header, both bgs are needed to hide elements during horizontal and vertical scroll */}
<Flex bg="bg" display="flex" position="sticky" pt={2} top={0} zIndex={2}>
<Box bg="bg" flexGrow={1} left={0} minWidth="200px" position="sticky" zIndex={1}>
<Flex flexDirection="column-reverse" height="100px" position="relative">
{Boolean(gridRuns?.length) && (
<>
<DurationTick bottom="92px" duration={max} />
<DurationTick bottom="46px" duration={max / 2} />
</>
)}
</Flex>
</Box>
{/* Duration bars */}
<Flex flexDirection="row-reverse" flexShrink={0}>
<Flex flexShrink={0} position="relative">
<DurationAxis top="100px" />
<DurationAxis top="50px" />
<DurationAxis top="4px" />
<Flex flexDirection="row-reverse">
{gridRuns?.map((dr: GridRunsResponse) => (
<Bar key={dr.run_id} max={max} onClick={handleColumnClick} run={dr} />
))}
</Flex>
{selectedIsVisible === undefined || !selectedIsVisible ? undefined : (
<Link to={`/dags/${dagId}`}>
<IconButton
aria-label={translate("grid.buttons.resetToLatest")}
height="98px"
loading={isLoading}
minW={0}
ml={1}
title={translate("grid.buttons.resetToLatest")}
variant="surface"
zIndex={1}
>
<FiChevronsRight />
</IconButton>
</Link>
)}
</Flex>
</Flex>
<Flex flexDirection="row-reverse">
</Flex>

{/* Grid body */}
<Flex height={`${rowVirtualizer.getTotalSize()}px`} position="relative">
<Box bg="bg" flexGrow={1} flexShrink={0} left={0} minWidth="200px" position="sticky" zIndex={1}>
<TaskNames nodes={flatNodes} onRowClick={handleRowClick} virtualItems={virtualItems} />
</Box>
<Flex flexDirection="row-reverse" flexShrink={0}>
{gridRuns?.map((dr: GridRunsResponse) => (
<Bar
<TaskInstancesColumn
key={dr.run_id}
max={max}
nodes={flatNodes}
onCellClick={() => setMode("TI")}
onColumnClick={() => setMode("run")}
onCellClick={handleCellClick}
run={dr}
virtualItems={virtualItems}
/>
))}
</Flex>
{selectedIsVisible === undefined || !selectedIsVisible ? undefined : (
<Link to={`/dags/${dagId}`}>
<IconButton
aria-label={translate("grid.buttons.resetToLatest")}
height="98px"
loading={isLoading}
minW={0}
ml={1}
title={translate("grid.buttons.resetToLatest")}
variant="surface"
zIndex={1}
>
<FiChevronsRight />
</IconButton>
</Link>
)}
</Flex>
</Box>
</Flex>
Expand Down
Loading