From b4ac8f53ef7f5e940a4040b018e2bbc7e216252a Mon Sep 17 00:00:00 2001 From: Rabah <106587883+RabahAmrouche05@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:50:27 +0200 Subject: [PATCH 1/2] UI: Group and filter Dags by folder on the Dags page Add a collapsible folder navigation tree to the Dags page, built from each Dag's relative_fileloc directory. Selecting a folder filters the list to the Dags it contains (and its subfolders) via a new server-side filter, so the result stays correct across pagination, sorting and other filters. Backend adds a relative_fileloc_prefix filter on GET /ui/dags and a new GET /ui/dags/folders endpoint returning the distinct folders of all readable Dags. Dags at the bundle root contribute no folder and appear under 'All Dags'. --- .../airflow/api_fastapi/common/parameters.py | 40 ++++ .../core_api/datamodels/ui/dags.py | 7 + .../core_api/openapi/_private_ui.yaml | 57 ++++++ .../api_fastapi/core_api/routes/ui/dags.py | 35 ++++ .../airflow/ui/openapi-gen/queries/common.ts | 9 +- .../ui/openapi-gen/queries/ensureQueryData.ts | 18 +- .../ui/openapi-gen/queries/prefetch.ts | 18 +- .../airflow/ui/openapi-gen/queries/queries.ts | 18 +- .../ui/openapi-gen/queries/suspense.ts | 18 +- .../ui/openapi-gen/requests/schemas.gen.ts | 20 ++ .../ui/openapi-gen/requests/services.gen.ts | 22 ++- .../ui/openapi-gen/requests/types.gen.ts | 24 +++ .../ui/public/i18n/locales/en/dags.json | 5 + .../airflow/ui/src/constants/searchParams.ts | 1 + .../DagFolderTree/DagFolderTree.test.tsx | 104 ++++++++++ .../DagsList/DagFolderTree/DagFolderTree.tsx | 178 ++++++++++++++++++ .../DagFolderTree/buildFolderTree.test.ts | 80 ++++++++ .../DagsList/DagFolderTree/buildFolderTree.ts | 73 +++++++ .../src/pages/DagsList/DagFolderTree/index.ts | 19 ++ .../ui/src/pages/DagsList/DagsList.tsx | 77 ++++++-- .../airflow/ui/src/queries/useDagFolders.ts | 35 ++++ .../src/airflow/ui/src/queries/useDags.tsx | 3 + .../core_api/routes/ui/test_dags.py | 89 +++++++++ 23 files changed, 920 insertions(+), 30 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts create mode 100644 airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts create mode 100644 airflow-core/src/airflow/ui/src/queries/useDagFolders.ts diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters.py b/airflow-core/src/airflow/api_fastapi/common/parameters.py index 56b4c20884cbb..f168c71a8410f 100644 --- a/airflow-core/src/airflow/api_fastapi/common/parameters.py +++ b/airflow-core/src/airflow/api_fastapi/common/parameters.py @@ -853,6 +853,43 @@ def depends(cls, owners: list[str] = Query(default_factory=list)) -> _OwnersFilt return cls().set_value(owners) +class _RelativeFilelocPrefixFilter(BaseParam[str | None]): + """ + Filter Dags by the folder they live in, derived from ``relative_fileloc``. + + The value is treated as a directory path relative to the bundle root (e.g. + ``team_a/etl``). It matches every Dag whose file lives directly in that folder + or in any of its subfolders, using an escaped ``LIKE 'team_a/etl/%'`` so a + folder name is never a substring/prefix of another (``team_a`` won't match + ``team_alpha``). Dags at the bundle root (no ``/`` in ``relative_fileloc``) + are not matched by any folder value and appear only when no folder is selected. + """ + + def to_orm(self, select: Select) -> Select: + if self.value is None and self.skip_none: + return select + + if not self.value: + return select + + directory = self.value.rstrip("/") + escaped = _escape_like_pattern(directory) + return select.where(DagModel.relative_fileloc.like(f"{escaped}/%", escape=_LIKE_ESCAPE_CHAR)) + + @classmethod + def depends( + cls, + relative_fileloc_prefix: str | None = Query( + default=None, + description=( + "Filter Dags by the folder (directory of ``relative_fileloc``) they live in. " + "Matches the given folder and all of its subfolders." + ), + ), + ) -> _RelativeFilelocPrefixFilter: + return cls().set_value(relative_fileloc_prefix) + + def _safe_parse_datetime(date_to_check: str) -> datetime: """ Parse datetime and raise error for invalid dates. @@ -1101,6 +1138,9 @@ def depends_float( ] QueryTagsFilter = Annotated[_TagsFilter, Depends(_TagsFilter.depends)] QueryOwnersFilter = Annotated[_OwnersFilter, Depends(_OwnersFilter.depends)] +QueryRelativeFilelocPrefixFilter = Annotated[ + _RelativeFilelocPrefixFilter, Depends(_RelativeFilelocPrefixFilter.depends) +] class _HasAssetScheduleFilter(BaseParam[bool]): diff --git a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py index fc2fe3ed2d2aa..2a1b1dc215ca4 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/datamodels/ui/dags.py @@ -37,3 +37,10 @@ class DAGWithLatestDagRunsCollectionResponse(BaseModel): total_entries: int dags: list[DAGWithLatestDagRunsResponse] + + +class DagFolderCollectionResponse(BaseModel): + """Collection of distinct Dag folders (directories of ``relative_fileloc``).""" + + folders: list[str] + total_entries: int diff --git a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml index de3f5869864b7..ffa0c155231e4 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml +++ b/airflow-core/src/airflow/api_fastapi/core_api/openapi/_private_ui.yaml @@ -473,6 +473,18 @@ paths: - type: string - type: 'null' title: Bundle Version + - name: relative_fileloc_prefix + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + description: Filter Dags by the folder (directory of ``relative_fileloc``) + they live in. Matches the given folder and all of its subfolders. + title: Relative Fileloc Prefix + description: Filter Dags by the folder (directory of ``relative_fileloc``) + they live in. Matches the given folder and all of its subfolders. - name: order_by in: query required: false @@ -538,6 +550,35 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /ui/dags/folders: + get: + tags: + - DAG + summary: Get Dag Folders + description: 'Get the distinct folders the readable Dags live in. + + + A folder is the directory part of a Dag''s ``relative_fileloc`` (relative + to its + + bundle root). Dags located directly at the bundle root have no folder and + are + + not represented here. The result powers the folder navigation tree in the + UI, + + which reconstructs the hierarchy by splitting each path on ``/``.' + operationId: get_dag_folders + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DagFolderCollectionResponse' + security: + - OAuth2PasswordBearer: [] + - HTTPBearer: [] /ui/dags/{dag_id}/latest_run: get: tags: @@ -2355,6 +2396,22 @@ components: - file_token title: DAGWithLatestDagRunsResponse description: DAG with latest dag runs response serializer. + DagFolderCollectionResponse: + properties: + folders: + items: + type: string + type: array + title: Folders + total_entries: + type: integer + title: Total Entries + type: object + required: + - folders + - total_entries + title: DagFolderCollectionResponse + description: Collection of distinct Dag folders (directories of ``relative_fileloc``). DagRunState: type: string enum: diff --git a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py index dfd3a71d815f5..90332c06a1731 100644 --- a/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py +++ b/airflow-core/src/airflow/api_fastapi/core_api/routes/ui/dags.py @@ -17,6 +17,7 @@ from __future__ import annotations +from pathlib import PurePosixPath from typing import Annotated from fastapi import Depends, HTTPException, status @@ -49,6 +50,7 @@ QueryOwnersFilter, QueryPausedFilter, QueryPendingActionsFilter, + QueryRelativeFilelocPrefixFilter, QueryTagsFilter, SortParam, filter_param_factory, @@ -57,6 +59,7 @@ from airflow.api_fastapi.core_api.datamodels.dags import DAG_ALIAS_MAPPING, DAGResponse from airflow.api_fastapi.core_api.datamodels.ui.dag_runs import DAGRunLightResponse from airflow.api_fastapi.core_api.datamodels.ui.dags import ( + DagFolderCollectionResponse, DAGWithLatestDagRunsCollectionResponse, DAGWithLatestDagRunsResponse, ) @@ -105,6 +108,7 @@ def get_dags( last_dag_run_state: QueryLastDagRunStateFilter, bundle_name: QueryBundleNameFilter, bundle_version: QueryBundleVersionFilter, + relative_fileloc_prefix: QueryRelativeFilelocPrefixFilter, order_by: Annotated[ SortParam, Depends( @@ -155,6 +159,7 @@ def get_dags( readable_dags_filter, bundle_name, bundle_version, + relative_fileloc_prefix, ], order_by=order_by, offset=offset, @@ -277,6 +282,36 @@ def get_dags( ) +@dags_router.get( + "/folders", + dependencies=[Depends(requires_access_dag(method="GET"))], + operation_id="get_dag_folders", +) +def get_dag_folders( + readable_dags_filter: ReadableDagsFilterDep, + session: SessionDep, +) -> DagFolderCollectionResponse: + """ + Get the distinct folders the readable Dags live in. + + A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its + bundle root). Dags located directly at the bundle root have no folder and are + not represented here. The result powers the folder navigation tree in the UI, + which reconstructs the hierarchy by splitting each path on ``/``. + """ + query = readable_dags_filter.to_orm( + select(DagModel.relative_fileloc).where(DagModel.relative_fileloc.is_not(None)).distinct() + ) + folders: set[str] = set() + for relative_fileloc in session.scalars(query): + parent = PurePosixPath(relative_fileloc).parent + if str(parent) != ".": + folders.add(str(parent)) + + sorted_folders = sorted(folders) + return DagFolderCollectionResponse(folders=sorted_folders, total_entries=len(sorted_folders)) + + @dags_router.get( "/{dag_id}/latest_run", responses=create_openapi_http_exception_doc([status.HTTP_404_NOT_FOUND]), diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts index 0933a3fea4d30..eb7ab18d240da 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts @@ -326,7 +326,7 @@ export const UseDagServiceGetDagTagsKeyFn = ({ limit, offset, orderBy, tagNamePa export type DagServiceGetDagsUiDefaultResponse = Awaited>; export type DagServiceGetDagsUiQueryResult = UseQueryResult; export const useDagServiceGetDagsUiKey = "DagServiceGetDagsUi"; -export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }: { +export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }: { assetDependency?: string; bundleName?: string; bundleVersion?: string; @@ -347,9 +347,14 @@ export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName, bundl orderBy?: string[]; owners?: string[]; paused?: boolean; + relativeFilelocPrefix?: string; tags?: string[]; tagsMatchMode?: "any" | "all"; -} = {}, queryKey?: Array) => [useDagServiceGetDagsUiKey, ...(queryKey ?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }])]; +} = {}, queryKey?: Array) => [useDagServiceGetDagsUiKey, ...(queryKey ?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }])]; +export type DagServiceGetDagFoldersDefaultResponse = Awaited>; +export type DagServiceGetDagFoldersQueryResult = UseQueryResult; +export const useDagServiceGetDagFoldersKey = "DagServiceGetDagFolders"; +export const UseDagServiceGetDagFoldersKeyFn = (queryKey?: Array) => [useDagServiceGetDagFoldersKey, ...(queryKey ?? [])]; export type DagServiceGetLatestRunInfoDefaultResponse = Awaited>; export type DagServiceGetLatestRunInfoQueryResult = UseQueryResult; export const useDagServiceGetLatestRunInfoKey = "DagServiceGetLatestRunInfo"; diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts index 1599fd104dfdc..77306f9d5f47c 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts @@ -665,6 +665,7 @@ export const ensureUseDagServiceGetDagTagsData = (queryClient: QueryClient, { li * @param data.lastDagRunState * @param data.bundleName * @param data.bundleVersion +* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of ``relative_fileloc``) they live in. Matches the given folder and all of its subfolders. * @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `dag_id, dag_display_name, next_dagrun, state, start_date, last_run_state, last_run_start_date` * @param data.isFavorite * @param data.hasAssetSchedule Filter Dags with asset-based scheduling @@ -673,7 +674,7 @@ export const ensureUseDagServiceGetDagTagsData = (queryClient: QueryClient, { li * @returns DAGWithLatestDagRunsCollectionResponse Successful Response * @throws ApiError */ -export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, { assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }: { +export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, { assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }: { assetDependency?: string; bundleName?: string; bundleVersion?: string; @@ -694,9 +695,22 @@ export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, { ass orderBy?: string[]; owners?: string[]; paused?: boolean; + relativeFilelocPrefix?: string; tags?: string[]; tagsMatchMode?: "any" | "all"; -} = {}) => queryClient.ensureQueryData({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }) }); +} = {}) => queryClient.ensureQueryData({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }) }); +/** +* Get Dag Folders +* Get the distinct folders the readable Dags live in. +* +* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its +* bundle root). Dags located directly at the bundle root have no folder and are +* not represented here. The result powers the folder navigation tree in the UI, +* which reconstructs the hierarchy by splitting each path on ``/``. +* @returns DagFolderCollectionResponse Successful Response +* @throws ApiError +*/ +export const ensureUseDagServiceGetDagFoldersData = (queryClient: QueryClient) => queryClient.ensureQueryData({ queryKey: Common.UseDagServiceGetDagFoldersKeyFn(), queryFn: () => DagService.getDagFolders() }); /** * Get Latest Run Info * Get latest run. diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts index 99940f89910e4..42b1911948fe4 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts @@ -665,6 +665,7 @@ export const prefetchUseDagServiceGetDagTags = (queryClient: QueryClient, { limi * @param data.lastDagRunState * @param data.bundleName * @param data.bundleVersion +* @param data.relativeFilelocPrefix Filter Dags by the folder (directory of ``relative_fileloc``) they live in. Matches the given folder and all of its subfolders. * @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `dag_id, dag_display_name, next_dagrun, state, start_date, last_run_state, last_run_start_date` * @param data.isFavorite * @param data.hasAssetSchedule Filter Dags with asset-based scheduling @@ -673,7 +674,7 @@ export const prefetchUseDagServiceGetDagTags = (queryClient: QueryClient, { limi * @returns DAGWithLatestDagRunsCollectionResponse Successful Response * @throws ApiError */ -export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, { assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }: { +export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, { assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }: { assetDependency?: string; bundleName?: string; bundleVersion?: string; @@ -694,9 +695,22 @@ export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, { asset orderBy?: string[]; owners?: string[]; paused?: boolean; + relativeFilelocPrefix?: string; tags?: string[]; tagsMatchMode?: "any" | "all"; -} = {}) => queryClient.prefetchQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }) }); +} = {}) => queryClient.prefetchQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }) }); +/** +* Get Dag Folders +* Get the distinct folders the readable Dags live in. +* +* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its +* bundle root). Dags located directly at the bundle root have no folder and are +* not represented here. The result powers the folder navigation tree in the UI, +* which reconstructs the hierarchy by splitting each path on ``/``. +* @returns DagFolderCollectionResponse Successful Response +* @throws ApiError +*/ +export const prefetchUseDagServiceGetDagFolders = (queryClient: QueryClient) => queryClient.prefetchQuery({ queryKey: Common.UseDagServiceGetDagFoldersKeyFn(), queryFn: () => DagService.getDagFolders() }); /** * Get Latest Run Info * Get latest run. diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts index ec7354f231985..6f7b486a60a08 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts @@ -665,6 +665,7 @@ export const useDagServiceGetDagTags = = unknown[]>({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }: { +export const useDagServiceGetDagsUi = = unknown[]>({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }: { assetDependency?: string; bundleName?: string; bundleVersion?: string; @@ -694,9 +695,22 @@ export const useDagServiceGetDagsUi = , "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }, queryKey), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }) as TData, ...options }); +} = {}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }, queryKey), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }) as TData, ...options }); +/** +* Get Dag Folders +* Get the distinct folders the readable Dags live in. +* +* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its +* bundle root). Dags located directly at the bundle root have no folder and are +* not represented here. The result powers the folder navigation tree in the UI, +* which reconstructs the hierarchy by splitting each path on ``/``. +* @returns DagFolderCollectionResponse Successful Response +* @throws ApiError +*/ +export const useDagServiceGetDagFolders = = unknown[]>(queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useQuery({ queryKey: Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () => DagService.getDagFolders() as TData, ...options }); /** * Get Latest Run Info * Get latest run. diff --git a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts index 5ba799cf780d0..8c0aed1006164 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts @@ -665,6 +665,7 @@ export const useDagServiceGetDagTagsSuspense = = unknown[]>({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }: { +export const useDagServiceGetDagsUiSuspense = = unknown[]>({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }: { assetDependency?: string; bundleName?: string; bundleVersion?: string; @@ -694,9 +695,22 @@ export const useDagServiceGetDagsUiSuspense = , "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }, queryKey), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, tags, tagsMatchMode }) as TData, ...options }); +} = {}, queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }, queryKey), queryFn: () => DagService.getDagsUi({ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix, tags, tagsMatchMode }) as TData, ...options }); +/** +* Get Dag Folders +* Get the distinct folders the readable Dags live in. +* +* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its +* bundle root). Dags located directly at the bundle root have no folder and are +* not represented here. The result powers the folder navigation tree in the UI, +* which reconstructs the hierarchy by splitting each path on ``/``. +* @returns DagFolderCollectionResponse Successful Response +* @throws ApiError +*/ +export const useDagServiceGetDagFoldersSuspense = = unknown[]>(queryKey?: TQueryKey, options?: Omit, "queryKey" | "queryFn">) => useSuspenseQuery({ queryKey: Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () => DagService.getDagFolders() as TData, ...options }); /** * Get Latest Run Info * Get latest run. diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts index 23170a462ca7e..2624d7f90b66d 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/schemas.gen.ts @@ -8827,6 +8827,26 @@ export const $DAGWithLatestDagRunsResponse = { description: 'DAG with latest dag runs response serializer.' } as const; +export const $DagFolderCollectionResponse = { + properties: { + folders: { + items: { + type: 'string' + }, + type: 'array', + title: 'Folders' + }, + total_entries: { + type: 'integer', + title: 'Total Entries' + } + }, + type: 'object', + required: ['folders', 'total_entries'], + title: 'DagFolderCollectionResponse', + description: 'Collection of distinct Dag folders (directories of ``relative_fileloc``).' +} as const; + export const $DagRunStatsResponse = { properties: { duration: { diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts index 4a4f368a225b1..1f6a7e795d666 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/services.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse2, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionTestData, GetConnectionTestResponse, EnqueueConnectionTestData, EnqueueConnectionTestResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, BulkDagRunsData, BulkDagRunsResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, ClearDagRunsData, ClearDagRunsResponse, GetDagRunStatsData, GetDagRunStatsResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskGroupInstancesData, PatchTaskGroupInstancesResponse, PatchTaskGroupInstancesDryRunData, PatchTaskGroupInstancesDryRunResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, GetHitlDetailTryDetailData, GetHitlDetailTryDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, ListAssetStoreData, ListAssetStoreResponse, ClearAssetStoreData, ClearAssetStoreResponse, GetAssetStoreData, GetAssetStoreResponse, SetAssetStoreData, SetAssetStoreResponse, DeleteAssetStoreData, DeleteAssetStoreResponse, ListTaskStoreData, ListTaskStoreResponse, ClearTaskStoreData, ClearTaskStoreResponse, GetTaskStoreData, GetTaskStoreResponse, SetTaskStoreData, SetTaskStoreResponse, PatchTaskStoreData, PatchTaskStoreResponse, DeleteTaskStoreData, DeleteTaskStoreResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, DeleteXcomEntryData, DeleteXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutResponse, GetAuthMenusResponse, GetCurrentUserInfoResponse, GenerateTokenData, GenerateTokenResponse2, GetPartitionedDagRunsData, GetPartitionedDagRunsResponse, GetPendingPartitionedDagRunData, GetPendingPartitionedDagRunResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, GetDeadlinesData, GetDeadlinesResponse, GetDagDeadlineAlertsData, GetDagDeadlineAlertsResponse, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesStreamData, GetGridTiSummariesStreamResponse, GetGanttDataData, GetGanttDataResponse, GetCalendarData, GetCalendarResponse, ListTeamsData, ListTeamsResponse } from './types.gen'; +import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData, GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse, GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData, CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse, GetAssetQueuedEventsData, GetAssetQueuedEventsResponse, DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData, GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse, DeleteDagAssetQueuedEventsData, DeleteDagAssetQueuedEventsResponse, GetDagAssetQueuedEventData, GetDagAssetQueuedEventResponse, DeleteDagAssetQueuedEventData, DeleteDagAssetQueuedEventResponse, NextRunAssetsData, NextRunAssetsResponse2, ListBackfillsData, ListBackfillsResponse, CreateBackfillData, CreateBackfillResponse, GetBackfillData, GetBackfillResponse, PauseBackfillData, PauseBackfillResponse, UnpauseBackfillData, UnpauseBackfillResponse, CancelBackfillData, CancelBackfillResponse, CreateBackfillDryRunData, CreateBackfillDryRunResponse, ListBackfillsUiData, ListBackfillsUiResponse, DeleteConnectionData, DeleteConnectionResponse, GetConnectionData, GetConnectionResponse, PatchConnectionData, PatchConnectionResponse, GetConnectionTestData, GetConnectionTestResponse, EnqueueConnectionTestData, EnqueueConnectionTestResponse, GetConnectionsData, GetConnectionsResponse, PostConnectionData, PostConnectionResponse, BulkConnectionsData, BulkConnectionsResponse, TestConnectionData, TestConnectionResponse, CreateDefaultConnectionsResponse, HookMetaDataResponse, GetDagRunData, GetDagRunResponse, DeleteDagRunData, DeleteDagRunResponse, PatchDagRunData, PatchDagRunResponse, BulkDagRunsData, BulkDagRunsResponse, GetDagRunsData, GetDagRunsResponse, TriggerDagRunData, TriggerDagRunResponse, GetUpstreamAssetEventsData, GetUpstreamAssetEventsResponse, ClearDagRunData, ClearDagRunResponse, WaitDagRunUntilFinishedData, WaitDagRunUntilFinishedResponse, GetListDagRunsBatchData, GetListDagRunsBatchResponse, ClearDagRunsData, ClearDagRunsResponse, GetDagRunStatsData, GetDagRunStatsResponse, GetDagSourceData, GetDagSourceResponse, GetDagStatsData, GetDagStatsResponse, GetConfigData, GetConfigResponse, GetConfigValueData, GetConfigValueResponse, GetConfigsResponse, ListDagWarningsData, ListDagWarningsResponse, GetDagsData, GetDagsResponse, PatchDagsData, PatchDagsResponse, GetDagData, GetDagResponse, PatchDagData, PatchDagResponse, DeleteDagData, DeleteDagResponse, GetDagDetailsData, GetDagDetailsResponse, FavoriteDagData, FavoriteDagResponse, UnfavoriteDagData, UnfavoriteDagResponse, GetDagTagsData, GetDagTagsResponse, GetDagsUiData, GetDagsUiResponse, GetDagFoldersResponse, GetLatestRunInfoData, GetLatestRunInfoResponse, GetEventLogData, GetEventLogResponse, GetEventLogsData, GetEventLogsResponse, GetExtraLinksData, GetExtraLinksResponse, GetTaskInstanceData, GetTaskInstanceResponse, PatchTaskInstanceData, PatchTaskInstanceResponse, DeleteTaskInstanceData, DeleteTaskInstanceResponse, GetMappedTaskInstancesData, GetMappedTaskInstancesResponse, GetTaskInstanceDependenciesByMapIndexData, GetTaskInstanceDependenciesByMapIndexResponse, GetTaskInstanceDependenciesData, GetTaskInstanceDependenciesResponse, GetTaskInstanceTriesData, GetTaskInstanceTriesResponse, GetMappedTaskInstanceTriesData, GetMappedTaskInstanceTriesResponse, GetMappedTaskInstanceData, GetMappedTaskInstanceResponse, PatchTaskInstanceByMapIndexData, PatchTaskInstanceByMapIndexResponse, GetTaskInstancesData, GetTaskInstancesResponse, BulkTaskInstancesData, BulkTaskInstancesResponse, GetTaskInstancesBatchData, GetTaskInstancesBatchResponse, GetTaskInstanceTryDetailsData, GetTaskInstanceTryDetailsResponse, GetMappedTaskInstanceTryDetailsData, GetMappedTaskInstanceTryDetailsResponse, PostClearTaskInstancesData, PostClearTaskInstancesResponse, PatchTaskGroupInstancesData, PatchTaskGroupInstancesResponse, PatchTaskGroupInstancesDryRunData, PatchTaskGroupInstancesDryRunResponse, PatchTaskInstanceDryRunByMapIndexData, PatchTaskInstanceDryRunByMapIndexResponse, PatchTaskInstanceDryRunData, PatchTaskInstanceDryRunResponse, GetLogData, GetLogResponse, GetExternalLogUrlData, GetExternalLogUrlResponse, UpdateHitlDetailData, UpdateHitlDetailResponse, GetHitlDetailData, GetHitlDetailResponse, GetHitlDetailTryDetailData, GetHitlDetailTryDetailResponse, GetHitlDetailsData, GetHitlDetailsResponse, GetImportErrorData, GetImportErrorResponse, GetImportErrorsData, GetImportErrorsResponse, GetJobsData, GetJobsResponse, GetPluginsData, GetPluginsResponse, ImportErrorsResponse, DeletePoolData, DeletePoolResponse, GetPoolData, GetPoolResponse, PatchPoolData, PatchPoolResponse, GetPoolsData, GetPoolsResponse, PostPoolData, PostPoolResponse, BulkPoolsData, BulkPoolsResponse, GetProvidersData, GetProvidersResponse, ListAssetStoreData, ListAssetStoreResponse, ClearAssetStoreData, ClearAssetStoreResponse, GetAssetStoreData, GetAssetStoreResponse, SetAssetStoreData, SetAssetStoreResponse, DeleteAssetStoreData, DeleteAssetStoreResponse, ListTaskStoreData, ListTaskStoreResponse, ClearTaskStoreData, ClearTaskStoreResponse, GetTaskStoreData, GetTaskStoreResponse, SetTaskStoreData, SetTaskStoreResponse, PatchTaskStoreData, PatchTaskStoreResponse, DeleteTaskStoreData, DeleteTaskStoreResponse, GetXcomEntryData, GetXcomEntryResponse, UpdateXcomEntryData, UpdateXcomEntryResponse, DeleteXcomEntryData, DeleteXcomEntryResponse, GetXcomEntriesData, GetXcomEntriesResponse, CreateXcomEntryData, CreateXcomEntryResponse, GetTasksData, GetTasksResponse, GetTaskData, GetTaskResponse, DeleteVariableData, DeleteVariableResponse, GetVariableData, GetVariableResponse, PatchVariableData, PatchVariableResponse, GetVariablesData, GetVariablesResponse, PostVariableData, PostVariableResponse, BulkVariablesData, BulkVariablesResponse, ReparseDagFileData, ReparseDagFileResponse, GetDagVersionData, GetDagVersionResponse, GetDagVersionsData, GetDagVersionsResponse, GetHealthResponse, GetVersionResponse, LoginData, LoginResponse, LogoutResponse, GetAuthMenusResponse, GetCurrentUserInfoResponse, GenerateTokenData, GenerateTokenResponse2, GetPartitionedDagRunsData, GetPartitionedDagRunsResponse, GetPendingPartitionedDagRunData, GetPendingPartitionedDagRunResponse, GetDependenciesData, GetDependenciesResponse, HistoricalMetricsData, HistoricalMetricsResponse, DagStatsResponse2, GetDeadlinesData, GetDeadlinesResponse, GetDagDeadlineAlertsData, GetDagDeadlineAlertsResponse, StructureDataData, StructureDataResponse2, GetDagStructureData, GetDagStructureResponse, GetGridRunsData, GetGridRunsResponse, GetGridTiSummariesStreamData, GetGridTiSummariesStreamResponse, GetGanttDataData, GetGanttDataResponse, GetCalendarData, GetCalendarResponse, ListTeamsData, ListTeamsResponse } from './types.gen'; export class AssetService { /** @@ -1895,6 +1895,7 @@ export class DagService { * @param data.lastDagRunState * @param data.bundleName * @param data.bundleVersion + * @param data.relativeFilelocPrefix Filter Dags by the folder (directory of ``relative_fileloc``) they live in. Matches the given folder and all of its subfolders. * @param data.orderBy Attributes to order by, multi criteria sort is supported. Prefix with `-` for descending order. Supported attributes: `dag_id, dag_display_name, next_dagrun, state, start_date, last_run_state, last_run_start_date` * @param data.isFavorite * @param data.hasAssetSchedule Filter Dags with asset-based scheduling @@ -1925,6 +1926,7 @@ export class DagService { last_dag_run_state: data.lastDagRunState, bundle_name: data.bundleName, bundle_version: data.bundleVersion, + relative_fileloc_prefix: data.relativeFilelocPrefix, order_by: data.orderBy, is_favorite: data.isFavorite, has_asset_schedule: data.hasAssetSchedule, @@ -1937,6 +1939,24 @@ export class DagService { }); } + /** + * Get Dag Folders + * Get the distinct folders the readable Dags live in. + * + * A folder is the directory part of a Dag's ``relative_fileloc`` (relative to its + * bundle root). Dags located directly at the bundle root have no folder and are + * not represented here. The result powers the folder navigation tree in the UI, + * which reconstructs the hierarchy by splitting each path on ``/``. + * @returns DagFolderCollectionResponse Successful Response + * @throws ApiError + */ + public static getDagFolders(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/ui/dags/folders' + }); + } + /** * Get Latest Run Info * Get latest run. diff --git a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts index 15d5005606906..16a5272f433b7 100644 --- a/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts +++ b/airflow-core/src/airflow/ui/openapi-gen/requests/types.gen.ts @@ -2212,6 +2212,14 @@ export type DAGWithLatestDagRunsResponse = { readonly file_token: string; }; +/** + * Collection of distinct Dag folders (directories of ``relative_fileloc``). + */ +export type DagFolderCollectionResponse = { + folders: Array<(string)>; + total_entries: number; +}; + /** * DAG Run statistics serializer for responses. */ @@ -3344,12 +3352,18 @@ export type GetDagsUiData = { orderBy?: Array<(string)>; owners?: Array<(string)>; paused?: boolean | null; + /** + * Filter Dags by the folder (directory of ``relative_fileloc``) they live in. Matches the given folder and all of its subfolders. + */ + relativeFilelocPrefix?: string | null; tags?: Array<(string)>; tagsMatchMode?: 'any' | 'all' | null; }; export type GetDagsUiResponse = DAGWithLatestDagRunsCollectionResponse; +export type GetDagFoldersResponse = DagFolderCollectionResponse; + export type GetLatestRunInfoData = { dagId: string; }; @@ -6154,6 +6168,16 @@ export type $OpenApiTs = { }; }; }; + '/ui/dags/folders': { + get: { + res: { + /** + * Successful Response + */ + 200: DagFolderCollectionResponse; + }; + }; + }; '/ui/dags/{dag_id}/latest_run': { get: { req: GetLatestRunInfoData; diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json index dc40390745709..2a41a26435886 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json @@ -22,6 +22,11 @@ }, "runIdPatternFilter": "Search Dag Runs" }, + "folders": { + "all": "All Dags", + "empty": "No folders", + "title": "Folders" + }, "ownerLink": "Owner link for {{owner}}", "runAndTaskActions": { "affectedTasks": { diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts b/airflow-core/src/airflow/ui/src/constants/searchParams.ts index f3583d449ced7..7af0e30c04c60 100644 --- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts +++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts @@ -29,6 +29,7 @@ export enum SearchParamsKeys { CREATED_AT_RANGE = "created_at_range", CURSOR = "cursor", DAG_DISPLAY_NAME_PATTERN = "dag_display_name_pattern", + DAG_FOLDER = "dag_folder", DAG_ID = "dag_id", DAG_ID_PATTERN = "dag_id_pattern", DAG_VERSION = "dag_version", diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx new file mode 100644 index 0000000000000..3a58f800f8471 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx @@ -0,0 +1,104 @@ +/*! + * 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 "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { BaseWrapper } from "src/utils/Wrapper"; + +import { DagFolderTree } from "./DagFolderTree"; + +const FOLDERS = ["team_a/etl", "team_a/report", "team_b/ml"]; + +describe("DagFolderTree", () => { + it("renders the top-level folders and an 'All Dags' entry", () => { + render(, { + wrapper: BaseWrapper, + }); + + expect(screen.getByText("folders.all")).toBeInTheDocument(); + expect(screen.getByText("team_a")).toBeInTheDocument(); + expect(screen.getByText("team_b")).toBeInTheDocument(); + }); + + it("keeps sub-folders collapsed until expanded", () => { + render(, { + wrapper: BaseWrapper, + }); + + expect(screen.queryByText("etl")).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByLabelText("Expand")[0] as HTMLElement); + + expect(screen.getByText("etl")).toBeInTheDocument(); + expect(screen.getByText("report")).toBeInTheDocument(); + }); + + it("auto-expands the ancestors of the selected folder", () => { + render(, { + wrapper: BaseWrapper, + }); + + expect(screen.getByText("etl")).toBeInTheDocument(); + }); + + it("calls onSelectFolder with the folder path when a folder is clicked", () => { + const onSelectFolder = vi.fn(); + + render(, { + wrapper: BaseWrapper, + }); + + fireEvent.click(screen.getByText("team_b")); + + expect(onSelectFolder).toHaveBeenCalledWith("team_b"); + }); + + it("clears the selection when 'All Dags' is clicked", () => { + const onSelectFolder = vi.fn(); + + render(, { + wrapper: BaseWrapper, + }); + + fireEvent.click(screen.getByText("folders.all")); + + expect(onSelectFolder).toHaveBeenCalledWith(undefined); + }); + + it("does not select the folder when toggling its expander", () => { + const onSelectFolder = vi.fn(); + + render(, { + wrapper: BaseWrapper, + }); + + fireEvent.click(screen.getAllByLabelText("Expand")[0] as HTMLElement); + + expect(onSelectFolder).not.toHaveBeenCalled(); + }); + + it("shows an empty message when there are no folders", () => { + render(, { + wrapper: BaseWrapper, + }); + + expect(screen.getByText("folders.empty")).toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx new file mode 100644 index 0000000000000..712c9597fe212 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx @@ -0,0 +1,178 @@ +/*! + * 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, Flex, Heading, Skeleton, Text, VStack } from "@chakra-ui/react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { FiChevronDown, FiChevronRight, FiFolder } from "react-icons/fi"; + +import { buildFolderTree, type FolderNode } from "./buildFolderTree"; + +type Props = { + readonly folders: ReadonlyArray; + readonly isLoading?: boolean; + readonly onSelectFolder: (path: string | undefined) => void; + readonly selectedFolder: string | undefined; +}; + +// Every ancestor folder of the selected path, so the tree opens to reveal the selection. +const ancestorPaths = (folder: string | undefined): Array => { + if (folder === undefined || folder === "") { + return []; + } + + const segments = folder.split("/"); + + return segments.map((_, index) => segments.slice(0, index + 1).join("/")); +}; + +type RowProps = { + readonly expanded: Set; + readonly node: FolderNode; + readonly onSelectFolder: (path: string | undefined) => void; + readonly onToggle: (path: string) => void; + readonly selectedFolder: string | undefined; +}; + +const FolderRow = ({ expanded, node, onSelectFolder, onToggle, selectedFolder }: RowProps) => { + const hasChildren = node.children.length > 0; + const isExpanded = expanded.has(node.path); + const isSelected = selectedFolder === node.path; + const depth = node.path.split("/").length - 1; + + return ( + + onSelectFolder(node.path)} + pl={`${depth * 16 + 4}px`} + py={1} + > + { + event.stopPropagation(); + if (hasChildren) { + onToggle(node.path); + } + }} + visibility={hasChildren ? "visible" : "hidden"} + > + {isExpanded ? : } + + + + + + {node.name} + + + {hasChildren && isExpanded ? ( + + {node.children.map((child) => ( + + ))} + + ) : undefined} + + ); +}; + +export const DagFolderTree = ({ folders, isLoading = false, onSelectFolder, selectedFolder }: Props) => { + const { t: translate } = useTranslation("dags"); + const tree = useMemo(() => buildFolderTree(folders), [folders]); + + const [expanded, setExpanded] = useState>(() => new Set(ancestorPaths(selectedFolder))); + + const handleToggle = (path: string) => { + setExpanded((previous) => { + const next = new Set(previous); + + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + + return next; + }); + }; + + return ( + + + {translate("folders.title")} + + {isLoading ? ( + + + + + + ) : ( + + onSelectFolder(undefined)} + pl="4px" + py={1} + > + + {translate("folders.all")} + + + {tree.length === 0 ? ( + + {translate("folders.empty")} + + ) : ( + tree.map((node) => ( + + )) + )} + + )} + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts new file mode 100644 index 0000000000000..8bd1c4984567f --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts @@ -0,0 +1,80 @@ +/*! + * 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 { describe, expect, it } from "vitest"; + +import { buildFolderTree } from "./buildFolderTree"; + +describe("buildFolderTree", () => { + it("returns an empty array for no folders", () => { + expect(buildFolderTree([])).toEqual([]); + }); + + it("builds single-level roots", () => { + const tree = buildFolderTree(["team_a", "team_b"]); + + expect(tree.map((node) => node.path)).toEqual(["team_a", "team_b"]); + expect(tree.every((node) => node.children.length === 0)).toBe(true); + }); + + it("nests sub-folders and keeps full paths", () => { + const tree = buildFolderTree(["team_a/etl", "team_b/ml"]); + + expect(tree.map((node) => node.path)).toEqual(["team_a", "team_b"]); + + const [teamA] = tree; + + expect(teamA?.name).toBe("team_a"); + expect(teamA?.children).toHaveLength(1); + expect(teamA?.children[0]?.name).toBe("etl"); + expect(teamA?.children[0]?.path).toBe("team_a/etl"); + }); + + it("synthesizes intermediate folders that contain no Dag of their own", () => { + const tree = buildFolderTree(["team_a/etl/extract"]); + + expect(tree).toHaveLength(1); + expect(tree[0]?.path).toBe("team_a"); + expect(tree[0]?.children[0]?.path).toBe("team_a/etl"); + expect(tree[0]?.children[0]?.children[0]?.path).toBe("team_a/etl/extract"); + }); + + it("merges a folder that is both a leaf and a parent", () => { + const tree = buildFolderTree(["team_a", "team_a/etl"]); + + expect(tree).toHaveLength(1); + expect(tree[0]?.path).toBe("team_a"); + expect(tree[0]?.children.map((node) => node.path)).toEqual(["team_a/etl"]); + }); + + it("deduplicates repeated folders", () => { + const tree = buildFolderTree(["team_a/etl", "team_a/etl"]); + + expect(tree).toHaveLength(1); + expect(tree[0]?.children).toHaveLength(1); + }); + + it("sorts siblings alphabetically at every level", () => { + const tree = buildFolderTree(["team_b/zeta", "team_b/alpha", "team_a"]); + + expect(tree.map((node) => node.name)).toEqual(["team_a", "team_b"]); + const teamB = tree.find((node) => node.name === "team_b"); + + expect(teamB?.children.map((node) => node.name)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts new file mode 100644 index 0000000000000..2a58f985b630b --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts @@ -0,0 +1,73 @@ +/*! + * 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. + */ +export type FolderNode = { + /** Direct child folders, keyed alphabetically by their display name. */ + readonly children: Array; + /** Last path segment, shown in the tree (e.g. ``etl``). */ + readonly name: string; + /** Full folder path from the bundle root (e.g. ``team_a/etl``), used as the filter value. */ + readonly path: string; +}; + +/** + * Build a nested folder tree from a flat list of folder paths. + * + * The backend only returns the directory of each Dag file (leaf folders), so intermediate + * folders that contain no Dag of their own — but do contain sub-folders — are synthesized + * here. Given ``["team_a/etl", "team_b/ml"]`` the ``team_a``/``team_b`` nodes are created even + * though no Dag lives directly in them. + * + * Children are sorted alphabetically at every level for a stable, predictable rendering. + */ +export const buildFolderTree = (folders: ReadonlyArray): Array => { + type MutableNode = { children: Map; name: string; path: string }; + + const roots = new Map(); + + for (const folder of folders) { + const segments = folder.split("/").filter((segment) => segment !== ""); + + let level = roots; + let prefix = ""; + + for (const segment of segments) { + prefix = prefix === "" ? segment : `${prefix}/${segment}`; + + let node = level.get(segment); + + if (node === undefined) { + node = { children: new Map(), name: segment, path: prefix }; + level.set(segment, node); + } + + level = node.children; + } + } + + const toSortedNodes = (level: Map): Array => + [...level.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map((node) => ({ + children: toSortedNodes(node.children), + name: node.name, + path: node.path, + })); + + return toSortedNodes(roots); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts new file mode 100644 index 0000000000000..8250e140f337d --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/index.ts @@ -0,0 +1,19 @@ +/*! + * 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. + */ +export { DagFolderTree } from "./DagFolderTree"; diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx index 29af6d24f2839..550da0bef8b21 100644 --- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx +++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx @@ -16,7 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { Heading, HStack, Skeleton, VStack, type SelectValueChangeDetails, Box } from "@chakra-ui/react"; +import { + Heading, + HStack, + Skeleton, + VStack, + type SelectValueChangeDetails, + Box, + Flex, +} from "@chakra-ui/react"; import type { ColumnDef } from "@tanstack/react-table"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; @@ -40,10 +48,12 @@ import { SearchParamsKeys, type SearchParamsKeysType } from "src/constants/searc import { useAdvancedSearch } from "src/hooks/useAdvancedSearch"; import { DagsLayout } from "src/layouts/DagsLayout"; import { useConfig } from "src/queries/useConfig"; +import { useDagFolders } from "src/queries/useDagFolders"; import { useDags } from "src/queries/useDags"; import { DAGImportErrors } from "../Dashboard/Stats/DAGImportErrors"; import { DagCard } from "./DagCard"; +import { DagFolderTree } from "./DagFolderTree"; import { DagTags } from "./DagTags"; import { DagsFilters } from "./DagsFilters"; import { Schedule } from "./Schedule"; @@ -172,6 +182,7 @@ const createColumns = ( ]; const { + DAG_FOLDER, FAVORITE, LAST_DAG_RUN_STATE, NAME_PATTERN, @@ -204,6 +215,9 @@ export const DagsList = () => { const { selectedTags, tagFilterMode: selectedMatchMode } = useTagFilter(); const pendingReviews = searchParams.get(NEEDS_REVIEW); const owners = searchParams.getAll(OWNERS); + const selectedFolder = searchParams.get(DAG_FOLDER) ?? undefined; + + const { folders, isLoading: foldersLoading } = useDagFolders(); const { setTableURLState, tableURLState } = useTableURLState(); @@ -230,6 +244,20 @@ export const DagsList = () => { setSearchParams(searchParams); }; + const handleFolderChange = (folder: string | undefined) => { + setTableURLState({ + pagination: { ...pagination, pageIndex: 0 }, + sorting, + }); + if (folder === undefined || folder === "") { + searchParams.delete(DAG_FOLDER); + } else { + searchParams.set(DAG_FOLDER, folder); + } + searchParams.delete(OFFSET); + setSearchParams(searchParams); + }; + let paused = defaultShowPaused; let isFavorite = undefined; let pendingHitl = undefined; @@ -266,6 +294,7 @@ export const DagsList = () => { owners, paused, pendingHitl, + relativeFilelocPrefix: selectedFolder, tags: selectedTags, tagsMatchMode: selectedMatchMode, }); @@ -304,24 +333,34 @@ export const DagsList = () => { ) : undefined} - - } - initialState={tableURLState} - isLoading={isLoading} - modelName="common:dag" - onDisplayToggleChange={setDisplay} - onStateChange={setTableURLState} - showDisplayToggle - showRowCountHeading={false} - skeletonCount={display === "card" ? 5 : undefined} - total={totalEntries} - /> - + + + + + + } + initialState={tableURLState} + isLoading={isLoading} + modelName="common:dag" + onDisplayToggleChange={setDisplay} + onStateChange={setTableURLState} + showDisplayToggle + showRowCountHeading={false} + skeletonCount={display === "card" ? 5 : undefined} + total={totalEntries} + /> + + ); }; diff --git a/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts b/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts new file mode 100644 index 0000000000000..147ef6ee0a183 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/queries/useDagFolders.ts @@ -0,0 +1,35 @@ +/*! + * 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 { useDagServiceGetDagFolders } from "openapi/queries"; + +/** + * Fetch the distinct folders (directories of ``relative_fileloc``) of all readable Dags. + * + * The list powers the folder navigation tree on the Dags page; the tree hierarchy is + * reconstructed client-side by splitting each path on ``/``. + */ +export const useDagFolders = () => { + const { data, error, isLoading } = useDagServiceGetDagFolders(); + + return { + error, + folders: data?.folders ?? [], + isLoading, + }; +}; diff --git a/airflow-core/src/airflow/ui/src/queries/useDags.tsx b/airflow-core/src/airflow/ui/src/queries/useDags.tsx index 4be8244ce73ce..63457c6099161 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDags.tsx +++ b/airflow-core/src/airflow/ui/src/queries/useDags.tsx @@ -38,6 +38,7 @@ export const useDags = ({ owners, paused, pendingHitl, + relativeFilelocPrefix, tags, tagsMatchMode, }: { @@ -54,6 +55,7 @@ export const useDags = ({ owners?: Array; paused?: boolean; pendingHitl?: boolean; + relativeFilelocPrefix?: string; tags?: Array; tagsMatchMode?: "all" | "any"; }) => { @@ -74,6 +76,7 @@ export const useDags = ({ orderBy, owners, paused, + relativeFilelocPrefix, tags, tagsMatchMode, }, diff --git a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py index bb94168bd57d7..4efe439c76964 100644 --- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py +++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_dags.py @@ -418,3 +418,92 @@ def test_is_favorite_field_user_specific(self, test_client, session): # Verify that DAG1 is not marked as favorite for the test user dag1_data = next(dag for dag in body["dags"] if dag["dag_id"] == DAG1_ID) assert dag1_data["is_favorite"] is False + + +# Maps dag_id -> relative_fileloc. ``team_alpha`` exists to prove that a folder +# name is never matched as a prefix of another (``team_a`` must not catch it), +# and ``root_dag.py`` lives at the bundle root (no folder). +FOLDER_DAGS = { + "folder_dag_a_etl_extract": "team_a/etl/extract.py", + "folder_dag_a_etl_load": "team_a/etl/load.py", + "folder_dag_a_report": "team_a/report.py", + "folder_dag_b_ml_train": "team_b/ml/train.py", + "folder_dag_alpha": "team_alpha/x.py", + "folder_dag_root": "root_dag.py", +} + + +class TestDagFolders(TestPublicDagEndpoint): + @pytest.fixture(autouse=True) + @provide_session + def setup_folder_dags(self, *, session: Session = NEW_SESSION) -> None: + for dag_id, relative_fileloc in FOLDER_DAGS.items(): + session.add( + DagModel( + dag_id=dag_id, + bundle_name="dag_maker", + relative_fileloc=relative_fileloc, + fileloc=f"/tmp/{relative_fileloc}", + is_stale=False, + is_paused=False, + ) + ) + session.commit() + + def test_get_dag_folders(self, test_client): + response = test_client.get("/dags/folders") + assert response.status_code == 200 + body = response.json() + # Distinct directories of every readable Dag, sorted. Root-level Dags + # (``root_dag.py``) contribute no folder. The pre-existing setup Dags all + # live at the bundle root, so they do not add folders either. + assert body["folders"] == ["team_a", "team_a/etl", "team_alpha", "team_b/ml"] + assert body["total_entries"] == 4 + + def test_get_dag_folders_should_response_401(self, unauthenticated_test_client): + response = unauthenticated_test_client.get("/dags/folders") + assert response.status_code == 401 + + def test_get_dag_folders_should_response_403(self, unauthorized_test_client): + response = unauthorized_test_client.get("/dags/folders") + assert response.status_code == 403 + + @pytest.mark.parametrize( + ("prefix", "expected_dag_ids"), + [ + pytest.param( + "team_a", + {"folder_dag_a_etl_extract", "folder_dag_a_etl_load", "folder_dag_a_report"}, + id="folder-with-subfolders", + ), + pytest.param( + "team_a/etl", + {"folder_dag_a_etl_extract", "folder_dag_a_etl_load"}, + id="nested-folder", + ), + pytest.param( + "team_a/etl/", + {"folder_dag_a_etl_extract", "folder_dag_a_etl_load"}, + id="trailing-slash-normalized", + ), + pytest.param("team_b", {"folder_dag_b_ml_train"}, id="intermediate-folder"), + pytest.param("team_b/ml", {"folder_dag_b_ml_train"}, id="leaf-folder"), + pytest.param("team_alpha", {"folder_dag_alpha"}, id="sibling-prefix-folder"), + pytest.param("does/not/exist", set(), id="no-match"), + ], + ) + def test_folder_filter(self, test_client, prefix, expected_dag_ids): + response = test_client.get("/dags", params={"relative_fileloc_prefix": prefix}) + assert response.status_code == 200 + returned = {dag["dag_id"] for dag in response.json()["dags"]} + # Intersect with our Dags so pre-existing setup Dags don't affect the assertion. + assert returned & set(FOLDER_DAGS) == expected_dag_ids + # ``team_a`` must never match ``team_alpha`` (and vice-versa). + if prefix == "team_a": + assert "folder_dag_alpha" not in returned + + def test_no_folder_filter_returns_all_folder_dags(self, test_client): + response = test_client.get("/dags", params={"limit": 100}) + assert response.status_code == 200 + returned = {dag["dag_id"] for dag in response.json()["dags"]} + assert set(FOLDER_DAGS) <= returned From 94181906c246a3a5cfbc3f97114504febee07a95 Mon Sep 17 00:00:00 2001 From: Rabah <106587883+RabahAmrouche05@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:30:28 +0200 Subject: [PATCH 2/2] Add newsfragment for Dags folder grouping feature --- airflow-core/newsfragments/68544.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 airflow-core/newsfragments/68544.feature.rst diff --git a/airflow-core/newsfragments/68544.feature.rst b/airflow-core/newsfragments/68544.feature.rst new file mode 100644 index 0000000000000..621cab46da967 --- /dev/null +++ b/airflow-core/newsfragments/68544.feature.rst @@ -0,0 +1 @@ +The Dags page now offers a collapsible folder navigation tree, built from each Dag's file location, that lets you browse and filter Dags by the folder they live in.