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 =

Reply via email to