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 b08af1239d8 Feat: Adding task-level filters to DAG view in graph tab
(#64271)
b08af1239d8 is described below
commit b08af1239d856ebdefc68da4f21eb2d3888ac327
Author: Itay Adler <[email protected]>
AuthorDate: Wed Apr 29 22:19:09 2026 +0300
Feat: Adding task-level filters to DAG view in graph tab (#64271)
* feat: added task level filters in dag view
* feat: moved to task stream filters
* feat: adding new hebrew translations for the new task-level filters
* feat: moving the filters to new search button
* fix: fixing searching params and adding ctrl f support
* fix: adding new hook test, reverting unnecessary placeholder
translations, moved new filters to components folder
* fix: reverting unnecessary translations placeholder
* fix: refactoring more ui code include converting to use state badges,
converting useOpenGroups to useGroups for reuse and more
* fix: fixing conflict with TaskInstance filters and reducing calls to
flattenGraphNodes
* fix: changing the keyboard hotkey to open new graph task filters
* fix: applying new useGroups hook in the new graph component test
* fix: adding hints to be more cleared about the task filters along with
fixing filter params
* fix: adding hint for keyboard shortcut
---
.../src/airflow/ui/public/i18n/locales/en/dag.json | 11 +
.../src/airflow/ui/public/i18n/locales/he/dag.json | 11 +
.../airflow/ui/public/i18n/locales/zh-TW/dag.json | 8 +
.../AttrSelectFilterMulti.tsx | 0
.../src/airflow/ui/src/components/Graph/Edge.tsx | 5 +-
.../airflow/ui/src/components/Graph/JoinNode.tsx | 2 +
.../airflow/ui/src/components/Graph/TaskNode.tsx | 8 +-
.../airflow/ui/src/components/Graph/graphTypes.ts | 5 +
.../ui/src/components/Graph/reactflowUtils.ts | 2 +
.../airflow/ui/src/components/GraphTaskFilters.tsx | 273 +++++++++++++++++
.../src/airflow/ui/src/constants/searchParams.ts | 6 +
.../src/context/{openGroups => groups}/Context.ts | 5 +-
.../GroupsProvider.tsx} | 28 +-
.../ui/src/context/{openGroups => groups}/index.ts | 4 +-
.../useOpenGroups.ts => groups/useGroups.ts} | 8 +-
.../ui/src/layouts/Details/DetailsLayout.tsx | 6 +-
.../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 4 +-
.../ui/src/layouts/Details/Graph/Graph.test.tsx | 4 +-
.../airflow/ui/src/layouts/Details/Graph/Graph.tsx | 119 ++------
.../Details/Graph/components/FitViewOnLayout.tsx} | 23 +-
.../Details/Graph/components/GraphControls.tsx | 42 +++
.../Graph/hooks/useFilteredNodesAndEdges.ts | 109 +++++++
.../Details/Graph/hooks/useGraphSearchParams.ts | 58 ++++
.../Details/Graph/useGraphFilteredNodes.test.ts | 330 +++++++++++++++++++++
.../layouts/Details/Graph/useGraphFilteredNodes.ts | 140 +++++++++
.../Details/Graph/utils/nodeColor.ts} | 26 +-
.../airflow/ui/src/layouts/Details/Grid/Grid.tsx | 4 +-
.../ui/src/layouts/Details/Grid/TaskNames.tsx | 4 +-
.../airflow/ui/src/layouts/Details/Grid/utils.ts | 12 +-
.../ui/src/layouts/Details/PanelButtons.tsx | 12 +-
.../ui/src/layouts/Details/TaskStreamFilter.tsx | 4 +-
.../ui/src/layouts/Details/ToggleGroups.tsx | 4 +-
.../src/airflow/ui/src/pages/Asset/AssetLayout.tsx | 6 +-
.../pages/Dag/Tasks/TaskFilters/TaskFilters.tsx | 2 +-
34 files changed, 1120 insertions(+), 165 deletions(-)
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 7e3ad3d65fb..16ad4bfef16 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -82,6 +82,7 @@
},
"navigation": {
"navigation": "Navigation: Shift+{{arrow}}",
+ "openGraphFilters": "Task Filters: Ctrl+Shift+F",
"toggleGroup": "Toggle group: Space"
},
"notFound": {
@@ -132,6 +133,16 @@
"graphDirection": {
"label": "Graph Direction"
},
+ "graphFilters": {
+ "clearFilters": "Clear Filters",
+ "durationGte": "Min duration (s)",
+ "durationGteHint": "For mapped tasks, measures total span across all
instances",
+ "mapIndex": "Min. map index",
+ "mapIndexHint": "Shows mapped tasks expanded to at least this index",
+ "selectStatus": "Select status",
+ "selectTaskGroup": "Select task group",
+ "title": "Task Filters"
+ },
"showVersionIndicator": {
"label": "Show Version Indicator",
"options": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
index b7fbb38cc7f..5d7b0fa8497 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
@@ -71,6 +71,7 @@
},
"navigation": {
"navigation": "ניווט: {{arrow}}",
+ "openGraphFilters": "פילטרים משימות: Ctrl+Shift+F",
"toggleGroup": "החלפת קבוצה: רווח"
},
"overview": {
@@ -119,6 +120,16 @@
"graphDirection": {
"label": "כיוון התרשים"
},
+ "graphFilters": {
+ "clearFilters": "נקה פילטרים",
+ "durationGte": "משך מינימלי (בשניות)",
+ "durationGteHint": "למשימות ממופות, מודד את סך כל הזמן לאורך כל המופעים",
+ "mapIndex": "אינדקס מיפוי מינימלי",
+ "mapIndexHint": "מציג משימות ממופות מורחבות לפחות באינדקס הזה",
+ "selectStatus": "בחר סטטוס",
+ "selectTaskGroup": "בחר קבוצת משימות",
+ "title": "פילטרים משימות"
+ },
"taskStreamFilter": {
"activeFilter": "מסנן פעיל",
"clearFilter": "נקה מסנן",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
index 9702718cfff..be9a80d3005 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-TW/dag.json
@@ -122,6 +122,14 @@
"graphDirection": {
"label": "圖表方向"
},
+ "graphFilters": {
+ "clearFilters": "清除篩選條件",
+ "durationGte": "最短持續時間 (秒)",
+ "mapIndex": "映射索引",
+ "selectStatus": "選擇狀態",
+ "selectTaskGroup": "選擇任務群組",
+ "title": "任務篩選條件"
+ },
"showVersionIndicator": {
"label": "版本指示器",
"options": {
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx
b/airflow-core/src/airflow/ui/src/components/AttrSelectFilterMulti.tsx
similarity index 100%
rename from
airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx
rename to airflow-core/src/airflow/ui/src/components/AttrSelectFilterMulti.tsx
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
index 244d440d10b..a4afa95c168 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/Edge.tsx
@@ -23,6 +23,7 @@ import type { Edge as EdgeType } from "@xyflow/react";
import { useNodesData } from "@xyflow/react";
import type { ElkPoint } from "elkjs";
+import { opacityStyle } from "./graphTypes";
import type { EdgeData } from "./reactflowUtils";
type Props = EdgeType<EdgeData>;
@@ -48,7 +49,7 @@ const CustomEdge = ({ data, source, target }: Props) => {
const edgeStrokeColor = isSelected ? (rest.edgeType === "data" ?
dataEdgeColor : blueColor) : strokeColor;
return (
- <>
+ <g {...opacityStyle(rest.isFiltered)}>
{rest.labels?.map(({ height, id, text, width, x, y }) => {
if (y === undefined || x === undefined) {
return undefined;
@@ -80,7 +81,7 @@ const CustomEdge = ({ data, source, target }: Props) => {
y={(point: ElkPoint) => point.y}
/>
))}
- </>
+ </g>
);
};
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/JoinNode.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/JoinNode.tsx
index 7c1020bdaed..d742de8e9ca 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/JoinNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/JoinNode.tsx
@@ -20,6 +20,7 @@ import { Box } from "@chakra-ui/react";
import type { NodeProps, Node as NodeType } from "@xyflow/react";
import { NodeWrapper } from "./NodeWrapper";
+import { opacityStyle } from "./graphTypes";
import type { CustomNodeProps } from "./reactflowUtils";
export const JoinNode = ({ data }: NodeProps<NodeType<CustomNodeProps,
"join">>) => (
@@ -29,6 +30,7 @@ export const JoinNode = ({ data }:
NodeProps<NodeType<CustomNodeProps, "join">>)
borderRadius={`${data.width}px`}
height={`${data.height}px`}
width={`${data.width}px`}
+ {...opacityStyle(data.isFiltered)}
/>
</NodeWrapper>
);
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
index 330528a4d82..303706ec4c1 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
@@ -23,11 +23,12 @@ import { useTranslation } from "react-i18next";
import { TaskIcon } from "src/assets/TaskIcon";
import { StateBadge } from "src/components/StateBadge";
import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
-import { useOpenGroups } from "src/context/openGroups";
+import { useGroups } from "src/context/groups";
import { NodeWrapper } from "./NodeWrapper";
import { SegmentedStateBar } from "./SegmentedStateBar";
import { TaskLink } from "./TaskLink";
+import { opacityStyle } from "./graphTypes";
import type { CustomNodeProps } from "./reactflowUtils";
export const TaskNode = ({
@@ -35,6 +36,7 @@ export const TaskNode = ({
childCount,
depth,
height = 0,
+ isFiltered,
isGroup,
isMapped,
isOpen,
@@ -49,7 +51,7 @@ export const TaskNode = ({
id,
}: NodeProps<NodeType<CustomNodeProps, "task">>) => {
const { t: translate } = useTranslation("components");
- const { toggleGroupId } = useOpenGroups();
+ const { toggleGroupId } = useGroups();
const onClick = () => {
if (isGroup) {
toggleGroupId(id);
@@ -83,7 +85,7 @@ export const TaskNode = ({
return (
<NodeWrapper>
- <Flex alignItems="center" cursor="default" flexDirection="column">
+ <Flex alignItems="center" cursor="default" flexDirection="column"
{...opacityStyle(isFiltered)}>
<TaskInstanceTooltip
openDelay={500}
positioning={{
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/graphTypes.ts
b/airflow-core/src/airflow/ui/src/components/Graph/graphTypes.ts
index c008b5dfc2e..39f57f68bf1 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/graphTypes.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/graphTypes.ts
@@ -25,6 +25,11 @@ import Edge from "./Edge";
import { JoinNode } from "./JoinNode";
import { TaskNode } from "./TaskNode";
+export const opacityStyle = (isFiltered: boolean | undefined) => ({
+ opacity: isFiltered ? 0.2 : 1,
+ transition: "opacity 0.2s",
+});
+
export const nodeTypes = {
asset: AssetNode,
"asset-alias": AliasNode,
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
index 9c6dcbdf133..f50025b6cf3 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/reactflowUtils.ts
@@ -29,6 +29,7 @@ export type CustomNodeProps = {
depth?: number;
height?: number;
id: string;
+ isFiltered?: boolean;
isGroup?: boolean;
isMapped?: boolean;
isOpen?: boolean;
@@ -123,6 +124,7 @@ type Edge = {
export type EdgeData = {
rest: {
edgeType?: "data" | "scheduling";
+ isFiltered?: boolean;
isSelected?: boolean;
isSetupTeardown?: boolean;
} & ElkExtendedEdge;
diff --git a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
new file mode 100644
index 00000000000..a9d672d9384
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
@@ -0,0 +1,273 @@
+/*!
+ * 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 {
+ Button,
+ IconButton,
+ type NumberInputValueChangeDetails,
+ Portal,
+ Separator,
+ Text,
+ VStack,
+} from "@chakra-ui/react";
+import { useEffect, useRef, useState } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+import { FiSearch } from "react-icons/fi";
+import { useParams, useSearchParams } from "react-router-dom";
+
+import type { TaskInstanceState } from "openapi/requests/types.gen";
+import { AttrSelectFilterMulti } from "src/components/AttrSelectFilterMulti";
+import { StateBadge } from "src/components/StateBadge";
+import { Select } from "src/components/ui";
+import { Menu } from "src/components/ui/Menu";
+import { NumberInputField, NumberInputRoot } from
"src/components/ui/NumberInput";
+import { SearchParamsKeys } from "src/constants/searchParams";
+import { taskInstanceStateOptions } from "src/constants/stateOptions";
+import { useGroups } from "src/context/groups";
+
+export const GraphTaskFilters = () => {
+ const { t: translate } = useTranslation(["dag", "tasks"]);
+ const { runId } = useParams();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const { allGroupIds: allTaskGroups, allOperators } = useGroups();
+
+ const selectedOperators =
searchParams.getAll(SearchParamsKeys.GRAPH_OPERATOR);
+ const selectedTaskGroups =
searchParams.getAll(SearchParamsKeys.GRAPH_TASK_GROUP);
+ const selectedStates =
searchParams.getAll(SearchParamsKeys.GRAPH_TASK_STATE);
+ const mapIndexParam = searchParams.get(SearchParamsKeys.GRAPH_MAP_INDEX) ??
"";
+ const durationGteParam =
searchParams.get(SearchParamsKeys.GRAPH_DURATION_GTE) ?? "";
+
+ const hasActiveFilters =
+ selectedOperators.length > 0 ||
+ selectedTaskGroups.length > 0 ||
+ selectedStates.length > 0 ||
+ mapIndexParam !== "" ||
+ durationGteParam !== "";
+
+ const stateItems = taskInstanceStateOptions.items.filter(
+ (item) => item.value !== "all" && item.value !== "none",
+ );
+
+ const allGraphFilterKeys = [
+ SearchParamsKeys.GRAPH_OPERATOR,
+ SearchParamsKeys.GRAPH_TASK_GROUP,
+ SearchParamsKeys.GRAPH_TASK_STATE,
+ SearchParamsKeys.GRAPH_MAP_INDEX,
+ SearchParamsKeys.GRAPH_DURATION_GTE,
+ ];
+
+ const handleMultiChange = (key: string) => (values: Array<string> |
undefined) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+
+ next.delete(key);
+ values?.forEach((val) => next.append(key, val));
+
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const handleNumberChange =
+ (key: string, condition: (n: number) => boolean) => (details:
NumberInputValueChangeDetails) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+
+ if (condition(details.valueAsNumber)) {
+ next.set(key, details.value);
+ } else {
+ next.delete(key);
+ }
+
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const clearAllFilters = () => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+
+ allGraphFilterKeys.forEach((key) => next.delete(key));
+
+ return next;
+ },
+ { replace: true },
+ );
+ };
+
+ const isInitialMount = useRef(true);
+
+ useEffect(() => {
+ if (isInitialMount.current) {
+ isInitialMount.current = false;
+
+ return;
+ }
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+
+ allGraphFilterKeys.forEach((key) => next.delete(key));
+
+ return next;
+ },
+ { replace: true },
+ );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [allOperators, allTaskGroups]);
+
+ const [isOpen, setIsOpen] = useState(false);
+
+ useHotkeys("mod+shift+f", () => setIsOpen(true), { preventDefault: true });
+
+ const panelTitle = translate("dag:panel.graphFilters.title");
+
+ return (
+ <Menu.Root
+ onOpenChange={({ open: nextOpen }) => setIsOpen(nextOpen)}
+ open={isOpen}
+ positioning={{ placement: "bottom-end" }}
+ >
+ <Menu.Trigger asChild>
+ <IconButton
+ aria-label={panelTitle}
+ colorPalette="brand"
+ size="md"
+ title={panelTitle}
+ variant={hasActiveFilters ? "solid" : "ghost"}
+ >
+ <FiSearch />
+ </IconButton>
+ </Menu.Trigger>
+ <Portal>
+ <Menu.Positioner>
+ <Menu.Content alignItems="start" display="flex"
flexDirection="column" gap={2} p={4}>
+ <Text fontSize="sm" fontWeight="semibold">
+ {panelTitle}
+ </Text>
+
+ <Separator my={2} />
+
+ {allOperators.length > 1 ? (
+ <VStack alignItems="flex-start" width="100%">
+ <Text fontSize="xs">{translate("tasks:selectOperator")}</Text>
+ <AttrSelectFilterMulti
+ displayPrefix={undefined}
+
handleSelect={handleMultiChange(SearchParamsKeys.GRAPH_OPERATOR)}
+ placeholderText={translate("tasks:selectOperator")}
+ selectedValues={selectedOperators}
+ values={allOperators}
+ />
+ </VStack>
+ ) : undefined}
+
+ {allTaskGroups.length > 0 ? (
+ <VStack alignItems="flex-start" width="100%">
+ <Text
fontSize="xs">{translate("dag:panel.graphFilters.selectTaskGroup")}</Text>
+ <AttrSelectFilterMulti
+ displayPrefix={undefined}
+
handleSelect={handleMultiChange(SearchParamsKeys.GRAPH_TASK_GROUP)}
+
placeholderText={translate("dag:panel.graphFilters.selectTaskGroup")}
+ selectedValues={selectedTaskGroups}
+ values={allTaskGroups}
+ />
+ </VStack>
+ ) : undefined}
+
+ {runId === undefined ? undefined : (
+ <>
+ <VStack alignItems="flex-start" width="100%">
+ <Text
fontSize="xs">{translate("dag:panel.graphFilters.selectStatus")}</Text>
+ <Select.Root
+ collection={taskInstanceStateOptions}
+ multiple
+ onValueChange={({ value }) =>
handleMultiChange(SearchParamsKeys.GRAPH_TASK_STATE)(value)}
+ value={selectedStates}
+ >
+ <Select.Trigger colorPalette="brand" minW="max-content">
+ <Select.ValueText
+
placeholder={translate("dag:panel.graphFilters.selectStatus")}
+ width="auto"
+ >
+ {() => selectedStates.join(", ") || undefined}
+ </Select.ValueText>
+ </Select.Trigger>
+ <Select.Content>
+ {stateItems.map((option) => (
+ <Select.Item item={option} key={option.value}>
+ <StateBadge state={option.value as
TaskInstanceState}>
+ {translate(option.label)}
+ </StateBadge>
+ </Select.Item>
+ ))}
+ </Select.Content>
+ </Select.Root>
+ </VStack>
+ <VStack alignItems="flex-start" width="100%">
+ <Text
fontSize="xs">{translate("dag:panel.graphFilters.mapIndex")}</Text>
+ <NumberInputRoot
+ min={0}
+
onValueChange={handleNumberChange(SearchParamsKeys.GRAPH_MAP_INDEX, (num) =>
num >= 0)}
+ size="sm"
+ value={mapIndexParam}
+ w="100%"
+ >
+ <NumberInputField
placeholder={translate("dag:panel.graphFilters.mapIndex")} />
+ </NumberInputRoot>
+ <Text color="fg.muted" fontSize="xs">
+ {translate("dag:panel.graphFilters.mapIndexHint")}
+ </Text>
+ </VStack>
+ <VStack alignItems="flex-start" width="100%">
+ <Text
fontSize="xs">{translate("dag:panel.graphFilters.durationGte")}</Text>
+ <NumberInputRoot
+ min={0}
+
onValueChange={handleNumberChange(SearchParamsKeys.GRAPH_DURATION_GTE, (num) =>
num > 0)}
+ size="sm"
+ step={0.1}
+ value={durationGteParam}
+ w="100%"
+ >
+ <NumberInputField
placeholder={translate("dag:panel.graphFilters.durationGte")} />
+ </NumberInputRoot>
+ <Text color="fg.muted" fontSize="xs">
+ {translate("dag:panel.graphFilters.durationGteHint")}
+ </Text>
+ </VStack>
+ </>
+ )}
+
+ {hasActiveFilters ? (
+ <Button onClick={clearAllFilters} size="sm" variant="outline"
width="100%">
+ {translate("dag:panel.graphFilters.clearFilters")}
+ </Button>
+ ) : undefined}
+ </Menu.Content>
+ </Menu.Positioner>
+ </Portal>
+ </Menu.Root>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 5fc9a215037..c6d349346b0 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -46,6 +46,11 @@ export enum SearchParamsKeys {
EXECUTOR_CLASS = "executor_class",
FAVORITE = "favorite",
GANTT = "gantt",
+ GRAPH_DURATION_GTE = "graph-duration_gte",
+ GRAPH_MAP_INDEX = "graph-map_index",
+ GRAPH_OPERATOR = "graph-operator",
+ GRAPH_TASK_GROUP = "graph-task_group",
+ GRAPH_TASK_STATE = "graph-task_state",
HOSTNAME = "hostname",
INCLUDED_EVENTS = "included_events",
JOB_STATE = "job_state",
@@ -89,6 +94,7 @@ export enum SearchParamsKeys {
SUBJECT_SEARCH = "subject_search",
TAGS = "tags",
TAGS_MATCH_MODE = "tags_match_mode",
+ TASK_GROUP = "task_group",
TASK_ID = "task_id",
TASK_ID_PATTERN = "task_id_pattern",
TASK_STATE = "task_state",
diff --git a/airflow-core/src/airflow/ui/src/context/openGroups/Context.ts
b/airflow-core/src/airflow/ui/src/context/groups/Context.ts
similarity index 87%
rename from airflow-core/src/airflow/ui/src/context/openGroups/Context.ts
rename to airflow-core/src/airflow/ui/src/context/groups/Context.ts
index b33c5aba550..83a5a2554e6 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/Context.ts
+++ b/airflow-core/src/airflow/ui/src/context/groups/Context.ts
@@ -18,12 +18,13 @@
*/
import { createContext } from "react";
-export type OpenGroupsContextType = {
+export type GroupsContextType = {
allGroupIds: Array<string>;
+ allOperators: Array<string>;
openGroupIds: Array<string>;
setAllGroupIds: (groupIds: Array<string>) => void;
setOpenGroupIds: (groupIds: Array<string>) => void;
toggleGroupId: (groupId: string) => void;
};
-export const OpenGroupsContext = createContext<OpenGroupsContextType |
undefined>(undefined);
+export const GroupsContext = createContext<GroupsContextType |
undefined>(undefined);
diff --git
a/airflow-core/src/airflow/ui/src/context/openGroups/OpenGroupsProvider.tsx
b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
similarity index 76%
rename from
airflow-core/src/airflow/ui/src/context/openGroups/OpenGroupsProvider.tsx
rename to airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
index cdf9b4b8243..2f486f15a2c 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/OpenGroupsProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
@@ -25,28 +25,25 @@ import { allGroupsKey, dependenciesKey, openGroupsKey }
from "src/constants/loca
import useSelectedVersion from "src/hooks/useSelectedVersion";
import { flattenGraphNodes } from "src/layouts/Details/Grid/utils";
-import { OpenGroupsContext, type OpenGroupsContextType } from "./Context";
+import { GroupsContext, type GroupsContextType } from "./Context";
type Props = {
readonly dagId: string;
} & PropsWithChildren;
-export const OpenGroupsProvider = ({ children, dagId }: Props) => {
+export const GroupsProvider = ({ children, dagId }: Props) => {
const [openGroupIds, setOpenGroupIds] =
useLocalStorage<Array<string>>(openGroupsKey(dagId), []);
const [allGroupIds, setAllGroupIds] =
useLocalStorage<Array<string>>(allGroupsKey(dagId), []);
- // use a ref to track the current allGroupIds without causing re-renders
const allGroupIdsRef = useRef(allGroupIds);
useEffect(() => {
allGroupIdsRef.current = allGroupIds;
}, [allGroupIds]);
- // For Graph view support: dependencies + selected version
const selectedVersion = useSelectedVersion();
const [dependencies] = useLocalStorage<"all" | "immediate" |
"tasks">(dependenciesKey(dagId), "tasks");
- // Fetch structure (minimal params if you want it lightweight for Grid)
const { data: structure = { edges: [], nodes: [] } } =
useStructureServiceStructureData(
{
dagId,
@@ -57,21 +54,17 @@ export const OpenGroupsProvider = ({ children, dagId }:
Props) => {
{ enabled: Boolean(dagId) && selectedVersion !== undefined },
);
- // Update allGroupIds whenever structure changes
- useEffect(() => {
- const observedGroupIds = flattenGraphNodes(structure.nodes).allGroupIds;
+ const { allGroupIds: observedGroupIds, allOperators } =
flattenGraphNodes(structure.nodes);
+ useEffect(() => {
if (JSON.stringify(observedGroupIds) !==
JSON.stringify(allGroupIdsRef.current)) {
setAllGroupIds(observedGroupIds);
}
- }, [structure.nodes, setAllGroupIds]);
+ }, [observedGroupIds, setAllGroupIds]);
- const debouncedSetOpenGroupIds = useDebouncedCallback(
- (newGroupIds: Array<string>) => {
- setOpenGroupIds(newGroupIds);
- },
- 100, // 100ms debounce for batch operations
- );
+ const debouncedSetOpenGroupIds = useDebouncedCallback((newGroupIds:
Array<string>) => {
+ setOpenGroupIds(newGroupIds);
+ }, 100);
const toggleGroupId = (groupId: string) => {
if (openGroupIds.includes(groupId)) {
@@ -86,13 +79,14 @@ export const OpenGroupsProvider = ({ children, dagId }:
Props) => {
setOpenGroupIds(newGroupIds);
};
- const value: OpenGroupsContextType = {
+ const value: GroupsContextType = {
allGroupIds,
+ allOperators,
openGroupIds,
setAllGroupIds,
setOpenGroupIds: setOpenGroupIdsImmediate,
toggleGroupId,
};
- return <OpenGroupsContext.Provider
value={value}>{children}</OpenGroupsContext.Provider>;
+ return <GroupsContext.Provider
value={value}>{children}</GroupsContext.Provider>;
};
diff --git a/airflow-core/src/airflow/ui/src/context/openGroups/index.ts
b/airflow-core/src/airflow/ui/src/context/groups/index.ts
similarity index 92%
rename from airflow-core/src/airflow/ui/src/context/openGroups/index.ts
rename to airflow-core/src/airflow/ui/src/context/groups/index.ts
index 402134670ae..3278e5bd3e0 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/index.ts
+++ b/airflow-core/src/airflow/ui/src/context/groups/index.ts
@@ -17,6 +17,6 @@
* under the License.
*/
-export * from "./OpenGroupsProvider";
-export * from "./useOpenGroups";
+export * from "./GroupsProvider";
+export * from "./useGroups";
export * from "./Context";
diff --git
a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
b/airflow-core/src/airflow/ui/src/context/groups/useGroups.ts
similarity index 77%
copy from airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
copy to airflow-core/src/airflow/ui/src/context/groups/useGroups.ts
index 8ef47b23d73..efdeebb530f 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
+++ b/airflow-core/src/airflow/ui/src/context/groups/useGroups.ts
@@ -18,13 +18,13 @@
*/
import { useContext } from "react";
-import { OpenGroupsContext, type OpenGroupsContextType } from "./Context";
+import { GroupsContext, type GroupsContextType } from "./Context";
-export const useOpenGroups = (): OpenGroupsContextType => {
- const context = useContext(OpenGroupsContext);
+export const useGroups = (): GroupsContextType => {
+ const context = useContext(GroupsContext);
if (context === undefined) {
- throw new Error("useOpenGroup must be used within a OpenGroupsProvider");
+ throw new Error("useGroups must be used within a GroupsProvider");
}
return context;
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 82d1f4dc13a..d56442db38a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -51,8 +51,8 @@ import type { DagView } from "src/constants/dagView";
import { DEFAULT_DAG_VIEW_KEY } from "src/constants/localStorage";
import { SearchParamsKeys } from "src/constants/searchParams";
import { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
+import { GroupsProvider } from "src/context/groups";
import { HoverProvider, useHover } from "src/context/hover";
-import { OpenGroupsProvider } from "src/context/openGroups";
import { useGridRuns } from "src/queries/useGridRuns.ts";
import { DagBreadcrumb } from "./DagBreadcrumb";
@@ -217,7 +217,7 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
return (
<HoverProvider>
- <OpenGroupsProvider dagId={dagId}>
+ <GroupsProvider dagId={dagId}>
<HStack justifyContent="space-between" mb={2}>
<DagBreadcrumb />
<Flex gap={1}>
@@ -422,7 +422,7 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
)}
</PanelGroup>
</Box>
- </OpenGroupsProvider>
+ </GroupsProvider>
</HoverProvider>
);
};
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 3f6fe580788..2c20ff0e2a0 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
@@ -23,8 +23,8 @@ import { useParams, useSearchParams } from "react-router-dom";
import { useGanttServiceGetGanttData } from "openapi/queries";
import type { DagRunState, DagRunType } from "openapi/requests/types.gen";
+import { useGroups } from "src/context/groups";
import { useHover } from "src/context/hover";
-import { useOpenGroups } from "src/context/openGroups";
import { useTimezone } from "src/context/timezone";
import { NavigationModes, useNavigation } from "src/hooks/navigation";
import {
@@ -71,7 +71,7 @@ export const Gantt = ({
const { dagId = "", runId = "" } = useParams();
const [searchParams] = useSearchParams();
- const { openGroupIds, toggleGroupId } = useOpenGroups();
+ const { openGroupIds, toggleGroupId } = useGroups();
const { selectedTimezone } = useTimezone();
const { setHoveredTaskId } = useHover();
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.test.tsx
index da463360e96..6bf16be2a52 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.test.tsx
@@ -62,8 +62,8 @@ vi.mock("src/hooks/useSelectedVersion", () => ({
default: vi.fn(() => 1),
}));
-vi.mock("src/context/openGroups", () => ({
- useOpenGroups: vi.fn(() => ({ allGroupIds: [], openGroupIds: [],
setAllGroupIds: vi.fn() })),
+vi.mock("src/context/groups", () => ({
+ useGroups: vi.fn(() => ({ allGroupIds: [], openGroupIds: [], setAllGroupIds:
vi.fn() })),
}));
vi.mock("@xyflow/react", async () => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index 6ceeb9bacdf..0cfb3e0d8e2 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -17,20 +17,10 @@
* under the License.
*/
import { Box, Spinner, useToken } from "@chakra-ui/react";
-import {
- ReactFlow,
- Controls,
- ControlButton,
- Background,
- MiniMap,
- useReactFlow,
- type Node as ReactFlowNode,
-} from "@xyflow/react";
+import { ReactFlow, Background, MiniMap, type Node as ReactFlowNode } from
"@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useEffect } from "react";
-import { useTranslation } from "react-i18next";
-import { MdCenterFocusStrong } from "react-icons/md";
-import { useParams, useSearchParams } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
import { useDagRunServiceGetDagRun, useStructureServiceStructureData } from
"openapi/queries";
@@ -40,90 +30,32 @@ import type { CustomNodeProps } from
"src/components/Graph/reactflowUtils";
import { type Direction, useGraphLayout } from
"src/components/Graph/useGraphLayout";
import { dependenciesKey, directionKey } from "src/constants/localStorage";
import { useColorMode } from "src/context/colorMode";
-import { useOpenGroups } from "src/context/openGroups";
+import { useGroups } from "src/context/groups";
import useSelectedVersion from "src/hooks/useSelectedVersion";
import { flattenGraphNodes } from "src/layouts/Details/Grid/utils.ts";
import { useDependencyGraph } from "src/queries/useDependencyGraph";
import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
import { getReactFlowThemeStyle } from "src/theme";
+import { FitViewOnLayout } from "./components/FitViewOnLayout";
+import { GraphControls } from "./components/GraphControls";
+import { useFilteredNodesAndEdges } from "./hooks/useFilteredNodesAndEdges";
+import { useGraphSearchParams } from "./hooks/useGraphSearchParams";
+import { useGraphFilteredNodes } from "./useGraphFilteredNodes";
+import { nodeColor } from "./utils/nodeColor";
+
// Hoisted to module scope so ReactFlow receives a stable reference and skips
// its internal shallow-equality check on every render.
const defaultEdgeOptions = { zIndex: 1 };
-// Fits the viewport whenever a new layout is committed. Must live inside
-// <ReactFlow> to call useReactFlow(). Using layoutData as the dep means it
-// only fires when ELK produces a new layout, not on task-instance updates or
-// selection changes (unlike the `fitView` prop, which runs on every re-mount).
-const FitViewOnLayout = ({ layoutData }: { readonly layoutData: object |
undefined }) => {
- const { fitView } = useReactFlow();
-
- useEffect(() => {
- if (layoutData !== undefined) {
- void fitView({ padding: 0.1 });
- }
- }, [fitView, layoutData]);
-
- return null;
-};
-
-const GraphControls = ({ selectedNodeId }: { readonly selectedNodeId?: string
}) => {
- const { t: translate } = useTranslation("components");
- const { fitView } = useReactFlow();
-
- return (
- <Controls showInteractive={false}>
- {selectedNodeId === undefined ? undefined : (
- <ControlButton
- aria-label={translate("graph.zoomToTask")}
- onClick={() => {
- void fitView({ duration: 500, nodes: [{ id: selectedNodeId }],
padding: 0.5 });
- }}
- title={translate("graph.zoomToTask")}
- >
- <MdCenterFocusStrong />
- </ControlButton>
- )}
- </Controls>
- );
-};
-
-const nodeColor = (
- { data: { depth, height, isOpen, taskInstance, width }, type }:
ReactFlowNode<CustomNodeProps>,
- evenColor?: string,
- oddColor?: string,
-) => {
- if (height === undefined || width === undefined || type === "join") {
- return "";
- }
-
- if (taskInstance?.state !== undefined && !isOpen) {
- return `var(--chakra-colors-${taskInstance.state}-solid)`;
- }
-
- if (isOpen && depth !== undefined && depth % 2 === 0) {
- return evenColor ?? "";
- } else if (isOpen) {
- return oddColor ?? "";
- }
-
- return "";
-};
-
export const Graph = () => {
const { colorMode = "light" } = useColorMode();
const { dagId = "", groupId, runId = "", taskId } = useParams();
- const [searchParams] = useSearchParams();
const selectedVersion = useSelectedVersion();
- const filterRoot = searchParams.get("root") ?? undefined;
- const includeUpstream = searchParams.get("upstream") === "true";
- const includeDownstream = searchParams.get("downstream") === "true";
- const depthParam = searchParams.get("depth");
- const depth = depthParam !== null && depthParam !== "" ?
parseInt(depthParam, 10) : undefined;
-
- const hasActiveFilter = includeUpstream || includeDownstream;
+ const { depth, filterRoot, graphFilters, hasActiveFilter, includeDownstream,
includeUpstream } =
+ useGraphSearchParams();
// corresponds to the "bg", "bg.emphasized", "border.inverted" semantic
tokens
const [oddLight, oddDark, evenLight, evenDark, selectedDarkColor,
selectedLightColor] = useToken("colors", [
@@ -135,7 +67,7 @@ export const Graph = () => {
"bg.emphasized",
]);
- const { allGroupIds, openGroupIds, setAllGroupIds } = useOpenGroups();
+ const { allGroupIds, openGroupIds, setAllGroupIds } = useGroups();
const [dependencies] = useLocalStorage<"all" | "immediate" |
"tasks">(dependenciesKey(dagId), "tasks");
const [direction] = useLocalStorage<Direction>(directionKey(dagId), "RIGHT");
@@ -198,8 +130,8 @@ export const Graph = () => {
});
const gridTISummaries = runId ? summariesByRunId.get(runId) : undefined;
- // Add task instances and selection state to node data without recalculating
layout.
- const nodes = data?.nodes.map((node) => {
+ // Add task instances to the node data but without having to recalculate how
the graph is laid out
+ const nodesWithTI = data?.nodes.map((node) => {
const taskInstance = gridTISummaries?.task_instances.find((ti) =>
ti.task_id === node.id);
return {
@@ -212,18 +144,15 @@ export const Graph = () => {
};
});
- // isSelected is intentionally absent here — Edge.tsx reads it directly from
- // the node store via useNodesData, so the edges array stays stable when only
- // the selected task changes, avoiding a full edge reconciliation pass.
- const edges = (data?.edges ?? []).map((edge) => ({
- ...edge,
- data: {
- ...edge.data,
- rest: {
- ...edge.data?.rest,
- },
- },
- }));
+ const baseFilteredNodes = useGraphFilteredNodes(nodesWithTI, graphFilters);
+
+ const { edges, nodes } = useFilteredNodesAndEdges({
+ baseFilteredNodes,
+ dagId,
+ groupId,
+ layoutEdges: data?.edges ?? [],
+ taskId,
+ });
const selectedNodeId = taskId ?? groupId;
diff --git
a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/FitViewOnLayout.tsx
similarity index 54%
copy from airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
copy to
airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/FitViewOnLayout.tsx
index 8ef47b23d73..7bbdbd60cca 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/FitViewOnLayout.tsx
@@ -16,16 +16,21 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useContext } from "react";
+import { useReactFlow } from "@xyflow/react";
+import { useEffect } from "react";
-import { OpenGroupsContext, type OpenGroupsContextType } from "./Context";
+// Fits the viewport whenever a new layout is committed. Must live inside
+// <ReactFlow> to call useReactFlow(). Using layoutData as the dep means it
+// only fires when ELK produces a new layout, not on task-instance updates or
+// selection changes (unlike the `fitView` prop, which runs on every re-mount).
+export const FitViewOnLayout = ({ layoutData }: { readonly layoutData: object
| undefined }) => {
+ const { fitView } = useReactFlow();
-export const useOpenGroups = (): OpenGroupsContextType => {
- const context = useContext(OpenGroupsContext);
+ useEffect(() => {
+ if (layoutData !== undefined) {
+ void fitView({ padding: 0.1 });
+ }
+ }, [fitView, layoutData]);
- if (context === undefined) {
- throw new Error("useOpenGroup must be used within a OpenGroupsProvider");
- }
-
- return context;
+ return null;
};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx
new file mode 100644
index 00000000000..84230df3720
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/components/GraphControls.tsx
@@ -0,0 +1,42 @@
+/*!
+ * 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 { ControlButton, Controls, useReactFlow } from "@xyflow/react";
+import { useTranslation } from "react-i18next";
+import { MdCenterFocusStrong } from "react-icons/md";
+
+export const GraphControls = ({ selectedNodeId }: { readonly selectedNodeId?:
string }) => {
+ const { t: translate } = useTranslation("components");
+ const { fitView } = useReactFlow();
+
+ return (
+ <Controls showInteractive={false}>
+ {selectedNodeId === undefined ? undefined : (
+ <ControlButton
+ aria-label={translate("graph.zoomToTask")}
+ onClick={() => {
+ void fitView({ duration: 500, nodes: [{ id: selectedNodeId }],
padding: 0.5 });
+ }}
+ title={translate("graph.zoomToTask")}
+ >
+ <MdCenterFocusStrong />
+ </ControlButton>
+ )}
+ </Controls>
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useFilteredNodesAndEdges.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useFilteredNodesAndEdges.ts
new file mode 100644
index 00000000000..d70a04290fb
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useFilteredNodesAndEdges.ts
@@ -0,0 +1,109 @@
+/*!
+ * 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 type { Edge as FlowEdgeType, Node as ReactFlowNode } from
"@xyflow/react";
+
+import type { CustomNodeProps, EdgeData } from
"src/components/Graph/reactflowUtils";
+
+type Props = {
+ baseFilteredNodes: Array<ReactFlowNode<CustomNodeProps>> | undefined;
+ dagId: string;
+ groupId?: string;
+ layoutEdges: Array<FlowEdgeType<EdgeData>>;
+ taskId?: string;
+};
+
+type Result = {
+ edges: Array<FlowEdgeType<EdgeData>>;
+ nodes: Array<ReactFlowNode<CustomNodeProps>> | undefined;
+};
+
+export const useFilteredNodesAndEdges = ({
+ baseFilteredNodes,
+ dagId,
+ groupId,
+ layoutEdges,
+ taskId,
+}: Props): Result => {
+ const taskFilteredNodeIds = new Set<string>();
+
+ for (const node of baseFilteredNodes ?? []) {
+ if (node.data.isFiltered) {
+ taskFilteredNodeIds.add(node.id);
+ }
+ }
+
+ let nodes: Array<ReactFlowNode<CustomNodeProps>> | undefined;
+
+ if (!baseFilteredNodes || taskFilteredNodeIds.size === 0) {
+ nodes = baseFilteredNodes;
+ } else {
+ const nodeTypeMap = new Map(baseFilteredNodes.map((node) => [node.id,
node.type]));
+
+ nodes = baseFilteredNodes.map((node) => {
+ if (node.type !== "join") {
+ return node;
+ }
+
+ const connectedIds = layoutEdges.flatMap((edge) => {
+ if (edge.source === node.id) {
+ return [edge.target];
+ }
+ if (edge.target === node.id) {
+ return [edge.source];
+ }
+
+ return [];
+ });
+
+ const connectedTaskIds = connectedIds.filter((id) => nodeTypeMap.get(id)
=== "task");
+ const isFiltered =
+ connectedTaskIds.length > 0 && connectedTaskIds.every((id) =>
taskFilteredNodeIds.has(id));
+
+ return { ...node, data: { ...node.data, isFiltered } };
+ });
+ }
+
+ const filteredNodeIds = new Set<string>();
+
+ for (const node of nodes ?? []) {
+ if (node.data.isFiltered) {
+ filteredNodeIds.add(node.id);
+ }
+ }
+
+ const edges = layoutEdges.map((edge) => ({
+ ...edge,
+ data: {
+ ...edge.data,
+ rest: {
+ ...edge.data?.rest,
+ isFiltered: filteredNodeIds.has(edge.source) ||
filteredNodeIds.has(edge.target),
+ isSelected:
+ taskId === edge.source ||
+ taskId === edge.target ||
+ groupId === edge.source ||
+ groupId === edge.target ||
+ edge.source === `dag:${dagId}` ||
+ edge.target === `dag:${dagId}`,
+ },
+ },
+ })) as Array<FlowEdgeType<EdgeData>>;
+
+ return { edges, nodes };
+};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useGraphSearchParams.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useGraphSearchParams.ts
new file mode 100644
index 00000000000..061ec91fa97
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/hooks/useGraphSearchParams.ts
@@ -0,0 +1,58 @@
+/*!
+ * 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 { useSearchParams } from "react-router-dom";
+
+import { SearchParamsKeys } from "src/constants/searchParams";
+
+import type { GraphFilterValues } from "../useGraphFilteredNodes";
+
+type GraphSearchParams = {
+ depth: number | undefined;
+ filterRoot: string | undefined;
+ graphFilters: GraphFilterValues;
+ hasActiveFilter: boolean;
+ includeDownstream: boolean;
+ includeUpstream: boolean;
+};
+
+export const useGraphSearchParams = (): GraphSearchParams => {
+ const [searchParams] = useSearchParams();
+
+ const filterRoot = searchParams.get("root") ?? undefined;
+ const includeUpstream = searchParams.get("upstream") === "true";
+ const includeDownstream = searchParams.get("downstream") === "true";
+ const depthParam = searchParams.get("depth");
+ const depth = depthParam !== null && depthParam !== "" ?
parseInt(depthParam, 10) : undefined;
+ const hasActiveFilter = includeUpstream || includeDownstream;
+
+ const durationParam = searchParams.get(SearchParamsKeys.GRAPH_DURATION_GTE);
+ const mapIndexParam = searchParams.get(SearchParamsKeys.GRAPH_MAP_INDEX);
+ const durationVal = durationParam === null ? Number.NaN :
Number(durationParam);
+ const mapIndexVal = mapIndexParam === null ? Number.NaN :
Number(mapIndexParam);
+
+ const graphFilters: GraphFilterValues = {
+ durationThreshold: Number.isNaN(durationVal) ? undefined : durationVal,
+ mapIndex: Number.isNaN(mapIndexVal) ? undefined : mapIndexVal,
+ selectedOperators: searchParams.getAll(SearchParamsKeys.GRAPH_OPERATOR),
+ selectedStates: searchParams.getAll(SearchParamsKeys.GRAPH_TASK_STATE),
+ selectedTaskGroups: searchParams.getAll(SearchParamsKeys.GRAPH_TASK_GROUP),
+ };
+
+ return { depth, filterRoot, graphFilters, hasActiveFilter,
includeDownstream, includeUpstream };
+};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
new file mode 100644
index 00000000000..7f486ba0364
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.test.ts
@@ -0,0 +1,330 @@
+/* eslint-disable max-lines */
+
+/*!
+ * 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 { renderHook } from "@testing-library/react";
+import type { Node as ReactFlowNode } from "@xyflow/react";
+import { describe, expect, it } from "vitest";
+
+import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
+
+import { type GraphFilterValues, useGraphFilteredNodes } from
"./useGraphFilteredNodes";
+
+const makeNode = (
+ overrides: { id?: string; type?: string } & Partial<CustomNodeProps> = {},
+): ReactFlowNode<CustomNodeProps> => ({
+ data: {
+ id: overrides.id ?? "task_a",
+ label: overrides.id ?? "task_a",
+ type: overrides.type ?? "task",
+ ...overrides,
+ },
+ id: overrides.id ?? "task_a",
+ position: { x: 0, y: 0 },
+ type: overrides.type ?? "task",
+});
+
+const noFilters: GraphFilterValues = {
+ selectedOperators: [],
+ selectedStates: [],
+ selectedTaskGroups: [],
+};
+
+describe("useGraphFilteredNodes", () => {
+ it("returns undefined when nodes are undefined", () => {
+ const { result } = renderHook(() => useGraphFilteredNodes(undefined,
noFilters));
+
+ expect(result.current).toBeUndefined();
+ });
+
+ it("returns nodes unchanged when no filters are active", () => {
+ const nodes = [makeNode({ id: "task_a" }), makeNode({ id: "task_b" })];
+ const { result } = renderHook(() => useGraphFilteredNodes(nodes,
noFilters));
+
+ expect(result.current).toBe(nodes);
+ });
+
+ it("does not filter non-task nodes", () => {
+ const joinNode: ReactFlowNode<CustomNodeProps> = {
+ data: { id: "join_1", label: "join_1", type: "join" },
+ id: "join_1",
+ position: { x: 0, y: 0 },
+ type: "join",
+ };
+ const filters: GraphFilterValues = { ...noFilters, selectedOperators:
["SomeOperator"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([joinNode],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("operator filter: does not filter a node whose operator matches", () => {
+ const node = makeNode({ operator: "BashOperator" });
+ const filters: GraphFilterValues = { ...noFilters, selectedOperators:
["BashOperator"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("operator filter: filters a node whose operator does not match", () => {
+ const node = makeNode({ operator: "PythonOperator" });
+ const filters: GraphFilterValues = { ...noFilters, selectedOperators:
["BashOperator"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("operator filter: filters a node with no operator set", () => {
+ const node = makeNode({ operator: undefined });
+ const filters: GraphFilterValues = { ...noFilters, selectedOperators:
["BashOperator"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("task group filter: does not filter a node that is the selected group
itself", () => {
+ const node = makeNode({ id: "group_a" });
+ const filters: GraphFilterValues = { ...noFilters, selectedTaskGroups:
["group_a"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("task group filter: does not filter a node whose id starts with the group
prefix", () => {
+ const node = makeNode({ id: "group_a.task_1" });
+ const filters: GraphFilterValues = { ...noFilters, selectedTaskGroups:
["group_a"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("task group filter: filters a node outside the selected group", () => {
+ const node = makeNode({ id: "task_standalone" });
+ const filters: GraphFilterValues = { ...noFilters, selectedTaskGroups:
["group_a"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("state filter: does not filter a node whose task instance state matches",
() => {
+ const node = makeNode({
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, selectedStates:
["success"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("state filter: filters a node whose state does not match", () => {
+ const node = makeNode({
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "failed",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, selectedStates:
["success"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it('state filter: treats missing taskInstance state as "none"', () => {
+ const node = makeNode({ taskInstance: undefined });
+ const filters: GraphFilterValues = { ...noFilters, selectedStates:
["none"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it('state filter: filters a node with no taskInstance when state filter is
not "none"', () => {
+ const node = makeNode({ taskInstance: undefined });
+ const filters: GraphFilterValues = { ...noFilters, selectedStates:
["success"] };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("mapIndex filter: filters a non-mapped node when mapIndex is set", () => {
+ const node = makeNode({ isMapped: false });
+ const filters: GraphFilterValues = { ...noFilters, mapIndex: 0 };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("mapIndex filter: filters a mapped node with no taskInstance when
mapIndex is set", () => {
+ const node = makeNode({ isMapped: true, taskInstance: undefined });
+ const filters: GraphFilterValues = { ...noFilters, mapIndex: 0 };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("mapIndex filter: does not filter a mapped node when mapIndex is within
the total count", () => {
+ const node = makeNode({
+ isMapped: true,
+ taskInstance: {
+ child_states: { success: 3 },
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, mapIndex: 2 };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("mapIndex filter: filters a mapped node when mapIndex is >= total count",
() => {
+ const node = makeNode({
+ isMapped: true,
+ taskInstance: {
+ child_states: { success: 3 },
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, mapIndex: 3 };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("durationThreshold filter: does not filter a node whose duration meets
the threshold", () => {
+ const node = makeNode({
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: "2024-01-01T00:00:10Z",
+ min_start_date: "2024-01-01T00:00:00Z",
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, durationThreshold: 10
}; // exactly 10s
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+
+ it("durationThreshold filter: filters a node whose duration is below the
threshold", () => {
+ const node = makeNode({
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: "2024-01-01T00:00:05Z",
+ min_start_date: "2024-01-01T00:00:00Z",
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, durationThreshold: 10
}; // 5s < 10s
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("durationThreshold filter: filters a node with missing start/end dates",
() => {
+ const node = makeNode({
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "running",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = { ...noFilters, durationThreshold: 5 };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("multiple filters: filters a node that fails any active filter", () => {
+ const node = makeNode({
+ operator: "BashOperator",
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: null,
+ min_start_date: null,
+ state: "failed",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = {
+ ...noFilters,
+ selectedOperators: ["BashOperator"],
+ selectedStates: ["success"],
+ };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(true);
+ });
+
+ it("multiple filters: does not filter a node that passes all active
filters", () => {
+ const node = makeNode({
+ operator: "BashOperator",
+ taskInstance: {
+ child_states: null,
+ dag_version_number: undefined,
+ max_end_date: "2024-01-01T00:00:15Z",
+ min_start_date: "2024-01-01T00:00:00Z",
+ state: "success",
+ task_display_name: "task_a",
+ task_id: "task_a",
+ },
+ });
+ const filters: GraphFilterValues = {
+ durationThreshold: 10,
+ selectedOperators: ["BashOperator"],
+ selectedStates: ["success"],
+ selectedTaskGroups: [],
+ };
+ const { result } = renderHook(() => useGraphFilteredNodes([node],
filters));
+
+ expect(result.current?.[0]?.data.isFiltered).toBe(false);
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
new file mode 100644
index 00000000000..8952ecfdc6a
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/useGraphFilteredNodes.ts
@@ -0,0 +1,140 @@
+/*!
+ * 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 type { Node as ReactFlowNode } from "@xyflow/react";
+import { useMemo } from "react";
+
+import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
+
+type GraphFilterValues = {
+ durationThreshold?: number;
+ mapIndex?: number;
+ selectedOperators: Array<string>;
+ selectedStates: Array<string>;
+ selectedTaskGroups: Array<string>;
+};
+
+const hasActiveFilters = (filters: GraphFilterValues): boolean =>
+ filters.selectedOperators.length > 0 ||
+ filters.selectedTaskGroups.length > 0 ||
+ filters.selectedStates.length > 0 ||
+ filters.durationThreshold !== undefined ||
+ filters.mapIndex !== undefined;
+
+const getTaskDurationSeconds = (
+ minStartDate: string | null | undefined,
+ maxEndDate: string | null | undefined,
+): number | undefined => {
+ if (
+ minStartDate === null ||
+ minStartDate === undefined ||
+ maxEndDate === null ||
+ maxEndDate === undefined
+ ) {
+ return undefined;
+ }
+ const start = new Date(minStartDate).getTime();
+ const end = new Date(maxEndDate).getTime();
+
+ if (Number.isNaN(start) || Number.isNaN(end)) {
+ return undefined;
+ }
+
+ return (end - start) / 1000;
+};
+
+const isNodeFiltered = (node: ReactFlowNode<CustomNodeProps>, filters:
GraphFilterValues): boolean => {
+ if (node.type !== "task") {
+ return false;
+ }
+
+ const { isMapped, operator, taskInstance } = node.data;
+
+ if (
+ filters.selectedOperators.length > 0 &&
+ (operator === undefined || operator === null ||
!filters.selectedOperators.includes(operator))
+ ) {
+ return true;
+ }
+
+ if (filters.selectedTaskGroups.length > 0) {
+ const matchesGroup = filters.selectedTaskGroups.some(
+ (group) => node.id === group || node.id.startsWith(`${group}.`),
+ );
+
+ if (!matchesGroup) {
+ return true;
+ }
+ }
+
+ if (filters.selectedStates.length > 0) {
+ const state = taskInstance?.state ?? "none";
+
+ if (!filters.selectedStates.includes(state)) {
+ return true;
+ }
+ }
+
+ if (filters.mapIndex !== undefined) {
+ if (!isMapped || taskInstance === undefined) {
+ return true;
+ }
+ const total = Object.values(taskInstance.child_states ?? {}).reduce(
+ (acc, currentValue) => acc + currentValue,
+ 0,
+ );
+
+ if (filters.mapIndex >= total) {
+ return true;
+ }
+ }
+
+ if (filters.durationThreshold !== undefined) {
+ const duration = getTaskDurationSeconds(taskInstance?.min_start_date,
taskInstance?.max_end_date);
+
+ if (duration === undefined || duration < filters.durationThreshold) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+export const useGraphFilteredNodes = (
+ nodes: Array<ReactFlowNode<CustomNodeProps>> | undefined,
+ filters: GraphFilterValues,
+): Array<ReactFlowNode<CustomNodeProps>> | undefined =>
+ useMemo(() => {
+ if (nodes === undefined) {
+ return undefined;
+ }
+
+ if (!hasActiveFilters(filters)) {
+ return nodes;
+ }
+
+ return nodes.map((node) => ({
+ ...node,
+ data: {
+ ...node.data,
+ isFiltered: isNodeFiltered(node, filters),
+ },
+ }));
+ }, [nodes, filters]);
+
+export type { GraphFilterValues };
diff --git
a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/utils/nodeColor.ts
similarity index 54%
rename from airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
rename to
airflow-core/src/airflow/ui/src/layouts/Details/Graph/utils/nodeColor.ts
index 8ef47b23d73..f3715ec3735 100644
--- a/airflow-core/src/airflow/ui/src/context/openGroups/useOpenGroups.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/utils/nodeColor.ts
@@ -16,16 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useContext } from "react";
+import type { Node as ReactFlowNode } from "@xyflow/react";
-import { OpenGroupsContext, type OpenGroupsContextType } from "./Context";
+import type { CustomNodeProps } from "src/components/Graph/reactflowUtils";
-export const useOpenGroups = (): OpenGroupsContextType => {
- const context = useContext(OpenGroupsContext);
+export const nodeColor = (
+ { data: { depth, height, isOpen, taskInstance, width }, type }:
ReactFlowNode<CustomNodeProps>,
+ evenColor?: string,
+ oddColor?: string,
+): string => {
+ if (height === undefined || width === undefined || type === "join") {
+ return "";
+ }
+
+ if (taskInstance?.state !== undefined && !isOpen) {
+ return `var(--chakra-colors-${taskInstance.state}-solid)`;
+ }
- if (context === undefined) {
- throw new Error("useOpenGroup must be used within a OpenGroupsProvider");
+ if (isOpen && depth !== undefined && depth % 2 === 0) {
+ return evenColor ?? "";
+ } else if (isOpen) {
+ return oddColor ?? "";
}
- return context;
+ return "";
};
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 61826299051..eed38a10ad4 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
@@ -26,7 +26,7 @@ import { useParams, useSearchParams } from "react-router-dom";
import type { DagRunState, DagRunType, GridRunsResponse } from
"openapi/requests";
import type { VersionIndicatorOptions } from
"src/constants/showVersionIndicatorOptions";
-import { useOpenGroups } from "src/context/openGroups";
+import { useGroups } from "src/context/groups";
import { NavigationModes, useNavigation } from "src/hooks/navigation";
import { useGridRuns } from "src/queries/useGridRuns.ts";
import { useGridStructure } from "src/queries/useGridStructure.ts";
@@ -82,7 +82,7 @@ export const Grid = ({
const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt);
- const { openGroupIds, toggleGroupId } = useOpenGroups();
+ const { openGroupIds, toggleGroupId } = useGroups();
const { dagId = "" } = useParams();
const [searchParams] = useSearchParams();
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 8b9910cf0b5..ae8f112859a 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
@@ -24,8 +24,8 @@ import { FiChevronUp } from "react-icons/fi";
import { Link as RouterLink, useParams, useSearchParams } from
"react-router-dom";
import { TaskName } from "src/components/TaskName";
+import { useGroups } from "src/context/groups";
import { useHover } from "src/context/hover";
-import { useOpenGroups } from "src/context/openGroups";
import { ROW_HEIGHT } from "./constants";
import type { GridTask } from "./utils";
@@ -42,7 +42,7 @@ const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`;
export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => {
const { t: translate } = useTranslation("dag");
const { hoveredTaskId, setHoveredTaskId } = useHover();
- const { toggleGroupId } = useOpenGroups();
+ const { toggleGroupId } = useGroups();
const { dagId = "", groupId, taskId } = useParams();
const [searchParams] = useSearchParams();
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 242004a9120..6625be0d1c0 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
@@ -92,19 +92,23 @@ export const flattenNodes = (
export const flattenGraphNodes = (
nodes: Array<NodeResponse>,
- depth: number = 0,
-): { allGroupIds: Array<string> } => {
+): { allGroupIds: Array<string>; allOperators: Array<string> } => {
let allGroupIds: Array<string> = [];
+ const operators = new Set<string>();
nodes.forEach((node) => {
+ if (node.operator !== undefined && node.operator !== null) {
+ operators.add(node.operator);
+ }
if (node.children) {
allGroupIds.push(node.id);
- const { allGroupIds: childGroupIds } = flattenGraphNodes(node.children,
depth + 1);
+ const { allGroupIds: childGroupIds, allOperators: childOperators } =
flattenGraphNodes(node.children);
allGroupIds = [...allGroupIds, ...childGroupIds];
+ childOperators.forEach((op) => operators.add(op));
}
});
- return { allGroupIds };
+ return { allGroupIds, allOperators: [...operators].sort() };
};
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 6a26cab3764..b8d82510339 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -43,6 +43,7 @@ import { useLocalStorage } from "usehooks-ts";
import { DagVersionSelect } from "src/components/DagVersionSelect";
import { directionOptions, type Direction } from
"src/components/Graph/useGraphLayout";
+import { GraphTaskFilters } from "src/components/GraphTaskFilters";
import { Tooltip } from "src/components/ui";
import { type ButtonGroupOption, ButtonGroupToggle } from
"src/components/ui/ButtonGroupToggle";
import type { DagView } from "src/constants/dagView";
@@ -217,6 +218,7 @@ export const PanelButtons = ({
<Flex alignItems="center" gap={1} justifyContent="space-between">
<ToggleGroups />
<TaskStreamFilter />
+ {dagView === "graph" && <GraphTaskFilters />}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<Popover.Root autoFocus={false} positioning={{ placement:
"bottom-end" }}>
<Popover.Trigger asChild>
@@ -351,7 +353,15 @@ export const PanelButtons = ({
</Flex>
</Flex>
- {dagView !== "graph" && (
+ {dagView === "graph" ? (
+ <Flex justifyContent="flex-end" mt={1}>
+ <Flex color="fg.muted" gap={2}>
+ <Tooltip
content={<Text>{translate("dag:navigation.openGraphFilters")}</Text>} portalled>
+ <LuKeyboard />
+ </Tooltip>
+ </Flex>
+ </Flex>
+ ) : (
<Flex justifyContent="space-between" mt={1}>
<GridFilters />
<Flex color="fg.muted" gap={2} justifyContent="flex-end" mt={1}>
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
index 6f3a63306ee..4d9337eb729 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
@@ -38,7 +38,7 @@ import { Tooltip } from "src/components/ui";
import { Menu } from "src/components/ui/Menu";
export const TaskStreamFilter = () => {
- const { t: translate } = useTranslation(["common", "components", "dag"]);
+ const { t: translate } = useTranslation(["common", "dag"]);
const { taskId: currentTaskId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
@@ -270,7 +270,7 @@ export const TaskStreamFilter = () => {
<Separator my={2} />
- {hasActiveFilter && filterRoot !== undefined ? (
+ {(includeUpstream || includeDownstream) && filterRoot !==
undefined ? (
<Menu.Item asChild value="clear">
<Button
onClick={() =>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/ToggleGroups.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/ToggleGroups.tsx
index 8abaa3cca3e..1b67bc168c5 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/ToggleGroups.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/ToggleGroups.tsx
@@ -20,11 +20,11 @@ import type { ButtonGroupProps } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { ExpandCollapseButtons } from "src/components/ExpandCollapseButtons";
-import { useOpenGroups } from "src/context/openGroups";
+import { useGroups } from "src/context/groups";
export const ToggleGroups = (props: ButtonGroupProps) => {
const { t: translate } = useTranslation();
- const { allGroupIds, openGroupIds, setOpenGroupIds } = useOpenGroups();
+ const { allGroupIds, openGroupIds, setOpenGroupIds } = useGroups();
// Don't show button if the DAG has no task groups
if (!allGroupIds.length) {
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
index 28a80cc56b9..e44540d2b59 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
@@ -30,7 +30,7 @@ import { BreadcrumbStats } from
"src/components/BreadcrumbStats";
import { useTableURLState } from "src/components/DataTable/useTableUrlState";
import { ProgressBar } from "src/components/ui";
import { SearchParamsKeys } from "src/constants/searchParams";
-import { OpenGroupsProvider } from "src/context/openGroups";
+import { GroupsProvider } from "src/context/groups";
import { AssetGraph } from "./AssetGraph";
import { AssetPanelButtons } from "./AssetPanelButtons";
@@ -112,9 +112,9 @@ export const AssetLayout = () => {
<Panel defaultSize={70} minSize={6}>
<Box height="100%" position="relative" pr={2}>
<AssetPanelButtons dependencyType={dependencyType}
setDependencyType={setDependencyType} />
- <OpenGroupsProvider dagId="~">
+ <GroupsProvider dagId="~">
<AssetGraph asset={asset} dependencyType={dependencyType} />
- </OpenGroupsProvider>
+ </GroupsProvider>
</Box>
</Panel>
<PanelResizeHandle
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx
index 298adea09b0..58d47f75f25 100644
---
a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx
@@ -21,11 +21,11 @@ import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import type { TaskCollectionResponse } from "openapi/requests";
+import { AttrSelectFilterMulti } from "src/components/AttrSelectFilterMulti";
import { SearchBar } from "src/components/SearchBar.tsx";
import { ResetButton } from "src/components/ui";
import { SearchParamsKeys, type SearchParamsKeysType } from
"src/constants/searchParams.ts";
import { AttrSelectFilter } from
"src/pages/Dag/Tasks/TaskFilters/AttrSelectFilter.tsx";
-import { AttrSelectFilterMulti } from
"src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx";
export const TaskFilters = ({ tasksData }: { readonly tasksData:
TaskCollectionResponse | undefined }) => {
const { MAPPED, NAME_PATTERN, OFFSET, OPERATOR, RETRIES, TRIGGER_RULE }:
SearchParamsKeysType =