diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 1e9e271b3c899..637b1752497c2 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -7,6 +7,7 @@ "Providers": "Providers", "Variables": "Variables" }, + "allOperators": "All Operators", "asset_one": "Asset", "asset_other": "Assets", "assetEvent_one": "Asset Event", diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/tasks.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/tasks.json new file mode 100644 index 0000000000000..113b0f01070ac --- /dev/null +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/tasks.json @@ -0,0 +1,10 @@ +{ + "mapped": "Mapped", + "notMapped": "Not mapped", + "retries": "Retries", + "searchTasks": "Search tasks", + "selectMapped": "Select mapped", + "selectOperator": "Select operators", + "selectRetryValues": "Select retry values", + "selectTriggerRules": "Select trigger rules" +} diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts b/airflow-core/src/airflow/ui/src/constants/searchParams.ts index 05165124950e6..cc68b52843d54 100644 --- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts +++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts @@ -35,12 +35,15 @@ export enum SearchParamsKeys { LOGICAL_DATE_GTE = "logical_date_gte", LOGICAL_DATE_LTE = "logical_date_lte", MAP_INDEX = "map_index", + MAPPED = "mapped", NAME_PATTERN = "name_pattern", OFFSET = "offset", + OPERATOR = "operator", OWNERS = "owners", PAUSED = "paused", POOL = "pool", RESPONSE_RECEIVED = "response_received", + RETRIES = "retries", RUN_AFTER_GTE = "run_after_gte", RUN_AFTER_LTE = "run_after_lte", RUN_ID = "run_id", @@ -54,6 +57,7 @@ export enum SearchParamsKeys { TAGS_MATCH_MODE = "tags_match_mode", TASK_ID = "task_id", TASK_ID_PATTERN = "task_id_pattern", + TRIGGER_RULE = "trigger_rule", TRIGGERING_USER_NAME_PATTERN = "triggering_user_name_pattern", TRY_NUMBER = "try_number", USER = "user", diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilter.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilter.tsx new file mode 100644 index 0000000000000..50ef4d4201066 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilter.tsx @@ -0,0 +1,60 @@ +/*! + * 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 CollectionItem, createListCollection, type SelectValueChangeDetails } from "@chakra-ui/react"; + +import { Select } from "src/components/ui"; + +type Props = { + readonly handleSelect: (value: CollectionItem) => void; + readonly placeholderText: string; + readonly selectedValue: string | undefined; + readonly values: Array<{ key: string; label: string }> | undefined; +}; + +export const AttrSelectFilter = ({ handleSelect, placeholderText, selectedValue, values }: Props) => { + const thingCollection = createListCollection({ items: values ?? [] }); + const handleValueChange = (details: SelectValueChangeDetails) => { + if (Array.isArray(details.value)) { + handleSelect(details.value[0]); + } + }; + const selectedDisplay = values?.find((item) => item.key === selectedValue); + + return ( + + + + {() => selectedDisplay?.label} + + + + {thingCollection.items.map((option) => ( + + {option.label} + + ))} + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx new file mode 100644 index 0000000000000..5a74c239dcb0a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/AttrSelectFilterMulti.tsx @@ -0,0 +1,75 @@ +/*! + * 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 CollectionItem, createListCollection } from "@chakra-ui/react"; +import type { SelectValueChangeDetails } from "@chakra-ui/react"; + +import { Select } from "src/components/ui"; + +type Props = { + readonly displayPrefix: string | undefined; + readonly handleSelect: (values: Array) => void; + readonly placeholderText: string; + readonly selectedValues: Array | undefined; + readonly values: Array | undefined; +}; + +export const AttrSelectFilterMulti = ({ + displayPrefix, + handleSelect, + placeholderText, + selectedValues, + values, +}: Props) => { + const thingCollection = createListCollection({ items: values ?? [] }); + + const handleValueChange = (details: SelectValueChangeDetails) => { + if (Array.isArray(details.value)) { + handleSelect(details.value); + } + }; + let displayValue = selectedValues?.join(", ") ?? undefined; + + if (displayValue !== undefined && displayPrefix !== undefined) { + displayValue = `${displayPrefix}: ${displayValue}`; + } + + // debugger; + return ( + + + + {() => displayValue} + + + + {thingCollection.items.map((option) => ( + + {option} + + ))} + + + ); +}; 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 new file mode 100644 index 0000000000000..3abd8b9e238c9 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx @@ -0,0 +1,136 @@ +/*! + * 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 { Box, HStack } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; + +import type { TaskCollectionResponse } from "openapi/requests"; +import { SearchBar } from "src/components/SearchBar.tsx"; +import { ResetButton } from "src/components/ui"; +import { SearchParamsKeys } 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, OPERATOR, RETRIES, TRIGGER_RULE } = SearchParamsKeys; + const { t: translate } = useTranslation("tasks"); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedOperators = searchParams.getAll(OPERATOR); + const selectedTriggerRules = searchParams.getAll(TRIGGER_RULE); + const selectedRetries = searchParams.getAll(RETRIES); + const selectedMapped = searchParams.get(MAPPED) ?? undefined; + + const handleSelectedOperators = (value: Array | undefined) => { + searchParams.delete(OPERATOR); + value?.forEach((x) => searchParams.append(OPERATOR, x)); + setSearchParams(searchParams); + }; + const handleSelectedRetries = (value: Array | undefined) => { + searchParams.delete(RETRIES); + value?.forEach((x) => searchParams.append(RETRIES, x)); + setSearchParams(searchParams); + }; + const handleSelectedTriggerRules = (value: Array | undefined) => { + searchParams.delete(TRIGGER_RULE); + value?.forEach((x) => searchParams.append(TRIGGER_RULE, x)); + setSearchParams(searchParams); + }; + const handleSelectedMapped = (value: string | undefined) => { + searchParams.delete(MAPPED); + if (value !== undefined) { + searchParams.set(MAPPED, value); + } + setSearchParams(searchParams); + }; + + const onClearFilters = () => { + setSearchParams(); + }; + + const allOperatorNames: Array = [ + ...new Set(tasksData?.tasks.map((task) => task.operator_name).filter((item) => item !== null) ?? []), + ]; + const allTriggerRules: Array = [ + ...new Set(tasksData?.tasks.map((task) => task.trigger_rule).filter((item) => item !== null) ?? []), + ]; + const allRetryValues: Array = [ + ...new Set( + tasksData?.tasks.map((task) => task.retries?.toString()).filter((item) => item !== undefined) ?? [], + ), + ]; + const allMappedValues = [ + { key: "true", label: translate("mapped") }, + { key: "false", label: translate("notMapped") }, + ]; + const taskNamePattern = searchParams.get(NAME_PATTERN) ?? ""; + const handleSearchChange = (value: string) => { + if (value) { + searchParams.set(NAME_PATTERN, value); + } else { + searchParams.delete(NAME_PATTERN); + } + setSearchParams(searchParams); + }; + + return ( + <> + + + + + + + + + + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.tsx index 063790aba478f..e378b5c63ae3c 100644 --- a/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Dag/Tasks/Tasks.tsx @@ -16,15 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -import { Heading, Skeleton, Box } from "@chakra-ui/react"; +import { Skeleton, Box } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; +import { useParams, useSearchParams } from "react-router-dom"; import { useTaskServiceGetTasks } from "openapi/queries"; import type { TaskResponse } from "openapi/requests/types.gen"; import { DataTable } from "src/components/DataTable"; import type { CardDef } from "src/components/DataTable/types"; import { ErrorAlert } from "src/components/ErrorAlert"; +import { SearchParamsKeys } from "src/constants/searchParams.ts"; +import { TaskFilters } from "src/pages/Dag/Tasks/TaskFilters/TaskFilters.tsx"; import { TaskCard } from "./TaskCard"; @@ -36,8 +38,16 @@ const cardDef = (dagId: string): CardDef => ({ }); export const Tasks = () => { - const { t: translate } = useTranslation(); const { dagId = "" } = useParams(); + const { MAPPED, NAME_PATTERN, OPERATOR, RETRIES, TRIGGER_RULE } = SearchParamsKeys; + const { t: translate } = useTranslation(); + const [searchParams] = useSearchParams(); + const selectedOperators = searchParams.getAll(OPERATOR); + const selectedTriggerRules = searchParams.getAll(TRIGGER_RULE); + const selectedRetries = searchParams.getAll(RETRIES); + const selectedMapped = searchParams.get(MAPPED) ?? undefined; + const namePattern = searchParams.get(NAME_PATTERN) ?? undefined; + const { data, error: tasksError, @@ -47,16 +57,46 @@ export const Tasks = () => { dagId, }); + const filterTasks = ({ + mapped, + operatorNames, + retryValues, + tasks, + triggerRuleNames, + }: { + mapped: string | undefined; + operatorNames: Array; + retryValues: Array; + tasks: Array; + triggerRuleNames: Array; + }) => + tasks.filter( + (task) => + (operatorNames.length === 0 || operatorNames.includes(task.operator_name as string)) && + (triggerRuleNames.length === 0 || triggerRuleNames.includes(task.trigger_rule as string)) && + (retryValues.length === 0 || retryValues.includes(task.retries?.toString() as string)) && + (mapped === undefined || task.is_mapped?.toString() === mapped) && + (namePattern === undefined || task.task_display_name?.toString().includes(namePattern)), + ); + + const filteredTasks = filterTasks({ + mapped: selectedMapped, + operatorNames: selectedOperators, + retryValues: selectedRetries, + tasks: data ? data.tasks : [], + triggerRuleNames: selectedTriggerRules, + }); + return ( - - {translate("task", { count: data?.total_entries ?? 0 })} - + + +