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,