This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun 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 cee37d9db98 feat: add depth filter to TaskStreamFilter in UI (#60549)
cee37d9db98 is described below

commit cee37d9db98b5464f20d0621690880a965bdd465
Author: Oscar Ligthart <[email protected]>
AuthorDate: Mon Jan 19 17:53:05 2026 +0100

    feat: add depth filter to TaskStreamFilter in UI (#60549)
    
    * feat: add depth filter to task stream filter
    
    * feat: add traverse mode
    
    * refactor: change mode to buttongroup
    
    * fix: reset mode on clearing filter
    
    * fix: lint
    
    * fix: pnpm lint
    
    * fix: file length
    
    * fix: use translate for input placeholder
---
 .../src/airflow/ui/public/i18n/locales/en/dag.json |   7 +
 .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx |  13 +-
 .../airflow/ui/src/layouts/Details/Graph/Graph.tsx |   3 +
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   |   3 +
 .../ui/src/layouts/Details/TaskStreamFilter.tsx    | 245 +++++++++++++--------
 .../src/airflow/ui/src/queries/useGridStructure.ts |   3 +
 6 files changed, 185 insertions(+), 89 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 f101d9eb044..e127ba2e33e 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
@@ -119,7 +119,14 @@
       "activeFilter": "Active filter",
       "clearFilter": "Clear Filter",
       "clickTask": "Click a task to select it as the filter root",
+      "depth": "Depth",
+      "direction": "Direction",
       "label": "Filter",
+      "mode": "Mode",
+      "modes": {
+        "static": "Static",
+        "traverse": "Traverse"
+      },
       "options": {
         "both": "Both upstream & downstream",
         "downstream": "Downstream",
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 1afbb7adb27..1c49d76ebba 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
@@ -37,7 +37,7 @@ import dayjs from "dayjs";
 import { useDeferredValue } from "react";
 import { Bar } from "react-chartjs-2";
 import { useTranslation } from "react-i18next";
-import { useParams, useNavigate, useLocation } from "react-router-dom";
+import { useParams, useNavigate, useLocation, useSearchParams } from 
"react-router-dom";
 
 import { useTaskInstanceServiceGetTaskInstances } from "openapi/queries";
 import type { DagRunState, DagRunType } from "openapi/requests/types.gen";
@@ -83,6 +83,7 @@ const MIN_BAR_WIDTH = 10;
 
 export const Gantt = ({ dagRunState, limit, runType, triggeringUser }: Props) 
=> {
   const { dagId = "", groupId: selectedGroupId, runId = "", taskId: 
selectedTaskId } = useParams();
+  const [searchParams] = useSearchParams();
   const { openGroupIds } = useOpenGroups();
   const deferredOpenGroupIds = useDeferredValue(openGroupIds);
   const { t: translate } = useTranslation("common");
@@ -92,6 +93,12 @@ export const Gantt = ({ dagRunState, limit, runType, 
triggeringUser }: Props) =>
   const navigate = useNavigate();
   const location = useLocation();
 
+  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;
+
   // Corresponds to border, brand.emphasized, and brand.muted
   const [
     lightGridColor,
@@ -114,7 +121,11 @@ export const Gantt = ({ dagRunState, limit, runType, 
triggeringUser }: Props) =>
   });
   const { data: dagStructure, isLoading: structureLoading } = 
useGridStructure({
     dagRunState,
+    depth,
+    includeDownstream,
+    includeUpstream,
     limit,
+    root: filterRoot,
     runType,
     triggeringUser,
   });
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 bd26ef5d5ee..21593ff128b 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
@@ -68,6 +68,8 @@ export const Graph = () => {
   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;
 
@@ -90,6 +92,7 @@ export const Graph = () => {
   const { data: graphData = { edges: [], nodes: [] } } = 
useStructureServiceStructureData(
     {
       dagId,
+      depth,
       externalDependencies: dependencies === "immediate",
       includeDownstream,
       includeUpstream,
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 2d79f17d505..481dcf08033 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
@@ -68,6 +68,8 @@ export const Grid = ({ dagRunState, limit, runType, 
showGantt, triggeringUser }:
   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 { data: gridRuns, isLoading } = useGridRuns({ dagRunState, limit, 
runType, triggeringUser });
 
@@ -85,6 +87,7 @@ export const Grid = ({ dagRunState, limit, runType, 
showGantt, triggeringUser }:
 
   const { data: dagStructure } = useGridStructure({
     dagRunState,
+    depth,
     hasActiveRun: gridRuns?.some((dr) => isStatePending(dr.state)),
     includeDownstream,
     includeUpstream,
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 1cd21878012..f0c2b471d12 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/TaskStreamFilter.tsx
@@ -16,50 +16,78 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Button, Portal, Text, VStack } from "@chakra-ui/react";
+import { Button, ButtonGroup, Input, Portal, Separator, Text, VStack } from 
"@chakra-ui/react";
+import { useEffect } from "react";
 import { useTranslation } from "react-i18next";
 import { FiChevronDown, FiFilter } from "react-icons/fi";
-import { Link, useParams, useSearchParams } from "react-router-dom";
+import { useParams, useSearchParams } from "react-router-dom";
 
 import { Menu } from "src/components/ui/Menu";
 
 export const TaskStreamFilter = () => {
-  const { t: translate } = useTranslation(["components", "dag"]);
+  const { t: translate } = useTranslation(["common", "components", "dag"]);
   const { taskId: currentTaskId } = useParams();
-  const [searchParams] = useSearchParams();
+  const [searchParams, setSearchParams] = useSearchParams();
 
   const filterRoot = searchParams.get("root") ?? undefined;
   const includeUpstream = searchParams.get("upstream") === "true";
   const includeDownstream = searchParams.get("downstream") === "true";
+  const depth = searchParams.get("depth") ?? undefined;
+  const mode = searchParams.get("mode") ?? "static";
 
+  const hasActiveFilter = includeUpstream || includeDownstream;
   const isCurrentTaskTheRoot = currentTaskId === filterRoot;
   const bothActive = isCurrentTaskTheRoot && includeUpstream && 
includeDownstream;
   const activeUpstream = isCurrentTaskTheRoot && includeUpstream && 
!includeDownstream;
   const activeDownstream = isCurrentTaskTheRoot && includeDownstream && 
!includeUpstream;
-  const hasActiveFilter = includeUpstream || includeDownstream;
 
-  const buildFilterSearch = (upstream: boolean, downstream: boolean, root?: 
string) => {
-    const newParams = new URLSearchParams(searchParams);
+  // In traverse mode, update the root when the selected task changes
+  useEffect(() => {
+    if (
+      mode === "traverse" &&
+      hasActiveFilter &&
+      currentTaskId !== undefined &&
+      currentTaskId !== "" &&
+      currentTaskId !== filterRoot
+    ) {
+      searchParams.set("root", currentTaskId);
+      setSearchParams(searchParams, { replace: true });
+    }
+  }, [currentTaskId, mode, hasActiveFilter, filterRoot, searchParams, 
setSearchParams]);
+
+  const buildFilterSearch = (options: {
+    depth?: string;
+    downstream: boolean;
+    root?: string;
+    upstream: boolean;
+  }) => {
+    const { depth: newDepth, downstream, root, upstream } = options;
+    const hasDirection = upstream || downstream;
+    const hasRoot = root !== undefined && root !== "";
+    const hasDepth = newDepth !== undefined && newDepth !== "";
 
     if (upstream) {
-      newParams.set("upstream", "true");
+      searchParams.set("upstream", "true");
     } else {
-      newParams.delete("upstream");
+      searchParams.delete("upstream");
     }
-
     if (downstream) {
-      newParams.set("downstream", "true");
+      searchParams.set("downstream", "true");
     } else {
-      newParams.delete("downstream");
+      searchParams.delete("downstream");
     }
-
-    if (root !== undefined && root !== "" && (upstream || downstream)) {
-      newParams.set("root", root);
+    if (hasRoot && hasDirection) {
+      searchParams.set("root", root);
     } else {
-      newParams.delete("root");
+      searchParams.delete("root");
+      searchParams.delete("mode");
     }
-
-    return newParams.toString();
+    if (hasDepth && hasDirection) {
+      searchParams.set("depth", newDepth);
+    } else {
+      searchParams.delete("depth");
+    }
+    setSearchParams(searchParams);
   };
 
   return (
@@ -107,87 +135,128 @@ export const TaskStreamFilter = () => {
               </Text>
             )}
 
-            <VStack align="stretch" gap={1} width="100%">
-              <Menu.Item asChild value="upstream">
-                <Button
-                  asChild
-                  color={activeUpstream ? "white" : undefined}
-                  colorPalette={activeUpstream ? "blue" : "gray"}
-                  disabled={currentTaskId === undefined}
-                  size="sm"
-                  variant={activeUpstream ? "solid" : "ghost"}
-                  width="100%"
-                >
-                  <Link
-                    replace
-                    style={{
-                      justifyContent: "flex-start",
-                      pointerEvents: currentTaskId === undefined ? "none" : 
"auto",
-                    }}
-                    to={{ search: buildFilterSearch(true, false, 
currentTaskId) }}
-                  >
-                    {translate("dag:panel.taskStreamFilter.options.upstream")}
-                  </Link>
-                </Button>
-              </Menu.Item>
+            <Separator my={2} />
+
+            {/* Direction Section */}
+            <VStack align="stretch" gap={2} width="100%">
+              <Text fontSize="xs" fontWeight="semibold">
+                {translate("dag:panel.taskStreamFilter.direction")}
+              </Text>
+              <VStack align="stretch" gap={1} width="100%">
+                {[
+                  { active: activeUpstream, down: false, key: "upstream", 
label: "upstream", up: true },
+                  { active: activeDownstream, down: true, key: "downstream", 
label: "downstream", up: false },
+                  { active: bothActive, down: true, key: "both", label: 
"both", up: true },
+                ].map(({ active, down, key, label, up }) => {
+                  const onClick = () =>
+                    buildFilterSearch({ depth, downstream: down, root: 
currentTaskId, upstream: up });
+
+                  return (
+                    <Button
+                      color={active ? "white" : undefined}
+                      colorPalette={active ? "brand" : "gray"}
+                      disabled={currentTaskId === undefined}
+                      justifyContent="flex-start"
+                      key={key}
+                      onClick={onClick}
+                      onKeyDown={(event) => {
+                        if (event.key === "Enter" || event.key === " ") {
+                          event.preventDefault();
+                          onClick();
+                        }
+                      }}
+                      size="sm"
+                      variant={active ? "solid" : "ghost"}
+                      width="100%"
+                    >
+                      
{translate(`dag:panel.taskStreamFilter.options.${label}`)}
+                    </Button>
+                  );
+                })}
+              </VStack>
+            </VStack>
 
-              <Menu.Item asChild value="downstream">
+            <Separator my={2} />
+
+            {/* Depth Section */}
+            <VStack align="stretch" gap={2} width="100%">
+              <Text fontSize="xs" fontWeight="semibold">
+                {translate("dag:panel.taskStreamFilter.depth")}
+              </Text>
+              <Input
+                disabled={currentTaskId === undefined || !hasActiveFilter}
+                min={0}
+                onChange={(event) => {
+                  const { value } = event.target;
+
+                  buildFilterSearch({
+                    depth: value,
+                    downstream: includeDownstream,
+                    root: filterRoot,
+                    upstream: includeUpstream,
+                  });
+                }}
+                onKeyDown={(event) => {
+                  event.stopPropagation();
+                }}
+                placeholder={translate("common:expression.all")}
+                size="sm"
+                type="number"
+                value={depth ?? ""}
+              />
+            </VStack>
+
+            <Separator my={2} />
+
+            {/* Mode Section */}
+            <VStack align="stretch" gap={2} width="100%">
+              <Text fontSize="xs" fontWeight="semibold">
+                {translate("dag:panel.taskStreamFilter.mode")}
+              </Text>
+              <ButtonGroup attached colorPalette="brand" size="sm" 
variant="outline" width="100%">
                 <Button
-                  asChild
-                  color={activeDownstream ? "white" : undefined}
-                  colorPalette={activeDownstream ? "blue" : "gray"}
-                  disabled={currentTaskId === undefined}
-                  size="sm"
-                  variant={activeDownstream ? "solid" : "ghost"}
-                  width="100%"
+                  disabled={!hasActiveFilter}
+                  flex="1"
+                  onClick={() => {
+                    searchParams.set("mode", "static");
+                    setSearchParams(searchParams);
+                  }}
+                  variant={mode === "static" ? "solid" : "outline"}
                 >
-                  <Link
-                    replace
-                    style={{
-                      justifyContent: "flex-start",
-                      pointerEvents: currentTaskId === undefined ? "none" : 
"auto",
-                    }}
-                    to={{ search: buildFilterSearch(false, true, 
currentTaskId) }}
-                  >
-                    
{translate("dag:panel.taskStreamFilter.options.downstream")}
-                  </Link>
+                  {translate("dag:panel.taskStreamFilter.modes.static")}
                 </Button>
-              </Menu.Item>
-
-              <Menu.Item asChild value="both">
                 <Button
-                  asChild
-                  color={bothActive ? "white" : undefined}
-                  colorPalette={bothActive ? "blue" : "gray"}
-                  disabled={currentTaskId === undefined}
-                  size="sm"
-                  variant={bothActive ? "solid" : "ghost"}
-                  width="100%"
+                  disabled={!hasActiveFilter}
+                  flex="1"
+                  onClick={() => {
+                    searchParams.set("mode", "traverse");
+                    setSearchParams(searchParams);
+                  }}
+                  variant={mode === "traverse" ? "solid" : "outline"}
                 >
-                  <Link
-                    replace
-                    style={{
-                      justifyContent: "flex-start",
-                      pointerEvents: currentTaskId === undefined ? "none" : 
"auto",
-                    }}
-                    to={{ search: buildFilterSearch(true, true, currentTaskId) 
}}
-                  >
-                    {translate("dag:panel.taskStreamFilter.options.both")}
-                  </Link>
+                  {translate("dag:panel.taskStreamFilter.modes.traverse")}
                 </Button>
-              </Menu.Item>
+              </ButtonGroup>
             </VStack>
 
+            <Separator my={2} />
+
             {hasActiveFilter && filterRoot !== undefined ? (
               <Menu.Item asChild value="clear">
-                <Button asChild size="sm" variant="outline" width="100%">
-                  <Link
-                    replace
-                    style={{ justifyContent: "center" }}
-                    to={{ search: buildFilterSearch(false, false) }}
-                  >
-                    {translate("dag:panel.taskStreamFilter.clearFilter")}
-                  </Link>
+                <Button
+                  onClick={() =>
+                    buildFilterSearch({
+                      depth: undefined,
+                      downstream: false,
+                      root: undefined,
+                      upstream: false,
+                    })
+                  }
+                  size="sm"
+                  variant="outline"
+                  width="100%"
+                >
+                  {translate("dag:panel.taskStreamFilter.clearFilter")}
                 </Button>
               </Menu.Item>
             ) : undefined}
diff --git a/airflow-core/src/airflow/ui/src/queries/useGridStructure.ts 
b/airflow-core/src/airflow/ui/src/queries/useGridStructure.ts
index a74483a6f15..bbae7d789da 100644
--- a/airflow-core/src/airflow/ui/src/queries/useGridStructure.ts
+++ b/airflow-core/src/airflow/ui/src/queries/useGridStructure.ts
@@ -24,6 +24,7 @@ import { useAutoRefresh } from "src/utils";
 
 export const useGridStructure = ({
   dagRunState,
+  depth,
   hasActiveRun,
   includeDownstream,
   includeUpstream,
@@ -33,6 +34,7 @@ export const useGridStructure = ({
   triggeringUser,
 }: {
   dagRunState?: DagRunState | undefined;
+  depth?: number | undefined;
   hasActiveRun?: boolean;
   includeDownstream?: boolean;
   includeUpstream?: boolean;
@@ -48,6 +50,7 @@ export const useGridStructure = ({
   const { data: dagStructure, ...rest } = useGridServiceGetDagStructure(
     {
       dagId,
+      depth,
       includeDownstream,
       includeUpstream,
       limit,

Reply via email to