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 e14854221ad Group and filter Dags by folder on the Dags page (#68544)
e14854221ad is described below
commit e14854221ad25391f883503c58de83775be736e4
Author: Rabah <[email protected]>
AuthorDate: Fri Oct 2 14:41:52 2026 +0200
Group and filter Dags by folder on the Dags page (#68544)
* 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.
Because relative_fileloc is relative to each bundle, folders are scoped to
their
bundle: in multi-bundle deployments the bundle is shown as the top-level
entry
with its folder tree nested underneath, so folders sharing the same path in
different bundles stay separate. Single-bundle deployments keep the flat
tree.
Backend adds a relative_fileloc_prefix filter on GET /ui/dags and a new
GET /ui/dags/folders endpoint returning the distinct (bundle, folder) pairs
of
all readable Dags. Dags at the bundle root contribute no folder and appear
under
'All Dags'. The folder sidebar is hidden for flat deployments with no
folders.
* Fix button style
* Fix CI
---------
Co-authored-by: pierrejeambrun <[email protected]>
---
.../api_fastapi/common/parameters/__init__.py | 1 +
.../airflow/api_fastapi/common/parameters/dag.py | 41 +++
.../api_fastapi/core_api/datamodels/ui/dags.py | 14 +
.../api_fastapi/core_api/openapi/_private_ui.yaml | 90 ++++++
.../airflow/api_fastapi/core_api/routes/ui/dags.py | 51 ++++
.../src/airflow/ui/openapi-gen/queries/common.ts | 9 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 25 +-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 25 +-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 25 +-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 25 +-
.../airflow/ui/openapi-gen/requests/schemas.gen.ts | 37 +++
.../ui/openapi-gen/requests/services.gen.ts | 29 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 32 ++
.../airflow/ui/public/i18n/locales/en/dags.json | 7 +
.../src/airflow/ui/src/constants/localStorage.ts | 1 +
.../src/airflow/ui/src/constants/searchParams.ts | 2 +
.../DagsList/DagFolderTree/DagFolderTree.test.tsx | 323 +++++++++++++++++++++
.../pages/DagsList/DagFolderTree/DagFolderTree.tsx | 216 ++++++++++++++
.../DagsList/DagFolderTree/buildFolderTree.test.ts | 119 ++++++++
.../DagsList/DagFolderTree/buildFolderTree.ts | 152 ++++++++++
.../ui/src/pages/DagsList/DagFolderTree/index.ts | 19 ++
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 188 ++++++++----
.../src/airflow/ui/src/queries/useDagFolders.ts | 35 +++
.../src/airflow/ui/src/queries/useDags.tsx | 6 +
.../api_fastapi/core_api/routes/ui/test_dags.py | 183 ++++++++++++
25 files changed, 1587 insertions(+), 68 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
b/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
index fa1a24866b0..d3dbbbeb11f 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/__init__.py
@@ -64,6 +64,7 @@ from airflow.api_fastapi.common.parameters.dag import (
QueryHasImportErrorsFilter as QueryHasImportErrorsFilter,
QueryOwnersFilter as QueryOwnersFilter,
QueryPausedFilter as QueryPausedFilter,
+ QueryRelativeFilelocPrefixFilter as QueryRelativeFilelocPrefixFilter,
QueryTagsFilter as QueryTagsFilter,
QueryTeamsFilter as QueryTeamsFilter,
QueryTimetableTypePrefixPatternSearch as
QueryTimetableTypePrefixPatternSearch,
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
b/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
index e5c469e4eb4..fd7ecdd11a5 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters/dag.py
@@ -216,6 +216,43 @@ def teams_filter_factory(
return depends_teams_filter
+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)
+
+
class _DagIdTagsFilter(BaseParam[_TagFilterModel]):
"""Filter rows by Dag tags through their ``dag_id``."""
@@ -367,6 +404,10 @@ QueryOwnersFilter = Annotated[_OwnersFilter,
Depends(_OwnersFilter.depends)]
QueryTeamsFilter = Annotated[_TeamsFilter, Depends(_TeamsFilter.depends)]
+QueryRelativeFilelocPrefixFilter = Annotated[
+ _RelativeFilelocPrefixFilter, Depends(_RelativeFilelocPrefixFilter.depends)
+]
+
# DagTags
QueryDagTagPatternSearch = Annotated[
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 c8236395d2c..8f13e9a2491 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
@@ -62,3 +62,17 @@ class DAGsRunStateCountsCollectionResponse(BaseModel):
dags: list[DAGRunStateCountsResponse]
state_count_limit: int
+
+
+class DagFolderResponse(BaseModel):
+ """A distinct Dag folder (directory of ``relative_fileloc``) within a
bundle."""
+
+ bundle_name: str
+ folder: str
+
+
+class DagFolderCollectionResponse(BaseModel):
+ """Collection of distinct Dag folders, each scoped to the bundle it
belongs to."""
+
+ folders: list[DagFolderResponse]
+ 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 e5e8a4db4a3..170393f4cde 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
@@ -731,6 +731,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
@@ -856,6 +868,52 @@ 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, scoped
to
+ their bundle.
+
+
+ A folder is the directory part of a Dag''s ``relative_fileloc``
(relative
+ to its
+
+ bundle root). Because ``relative_fileloc`` is relative to each bundle,
the
+ same
+
+ path can exist in several bundles, so every folder is paired with its
bundle
+
+ name to keep them apart. 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
``/`` and
+
+ groups it under its bundle when more than one bundle is present.
+
+
+ Stale Dags are left out to match the Dag list, which hides them by
default:
+ keeping
+
+ them would surface folders (or whole bundles, once they stop being
parsed)
+ that
+
+ select down to an empty list.'
+ 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:
@@ -3379,6 +3437,38 @@ components:
- state_count_limit
title: DAGsRunStateCountsCollectionResponse
description: Collection of per-Dag DagRun-state counts for the Dag list
page.
+ DagFolderCollectionResponse:
+ properties:
+ folders:
+ items:
+ $ref: '#/components/schemas/DagFolderResponse'
+ 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, each scoped to the
bundle it
+ belongs to.
+ DagFolderResponse:
+ properties:
+ bundle_name:
+ type: string
+ title: Bundle Name
+ folder:
+ type: string
+ title: Folder
+ type: object
+ required:
+ - bundle_name
+ - folder
+ title: DagFolderResponse
+ description: A distinct Dag folder (directory of ``relative_fileloc``)
within
+ a bundle.
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 eb7dd48b279..55b1202cbc8 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
@@ -18,6 +18,7 @@
from __future__ import annotations
from collections.abc import Sequence
+from pathlib import PurePosixPath
from typing import Annotated
from fastapi import Depends, HTTPException, Query, status
@@ -52,6 +53,7 @@ from airflow.api_fastapi.common.parameters import (
QueryOwnersFilter,
QueryPausedFilter,
QueryPendingActionsFilter,
+ QueryRelativeFilelocPrefixFilter,
QueryTagsFilter,
QueryTeamsFilter,
QueryTimetableTypePrefixPatternSearch,
@@ -62,6 +64,8 @@ from airflow.api_fastapi.common.router import AirflowRouter
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,
+ DagFolderResponse,
DAGRunStateCountsResponse,
DAGsRunStateCountsCollectionResponse,
DagTimetableTypeCollectionResponse,
@@ -120,6 +124,7 @@ def get_dags(
dag_run_state: QueryAnyDagRunStateFilter,
bundle_name: QueryBundleNameFilter,
bundle_version: QueryBundleVersionFilter,
+ relative_fileloc_prefix: QueryRelativeFilelocPrefixFilter,
order_by: Annotated[
SortParam,
Depends(
@@ -182,6 +187,7 @@ def get_dags(
readable_dags_filter,
bundle_name,
bundle_version,
+ relative_fileloc_prefix,
],
order_by=order_by,
offset=offset,
@@ -342,6 +348,51 @@ def get_dag_timetable_types(
)
+@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, scoped to their bundle.
+
+ A folder is the directory part of a Dag's ``relative_fileloc`` (relative
to its
+ bundle root). Because ``relative_fileloc`` is relative to each bundle, the
same
+ path can exist in several bundles, so every folder is paired with its
bundle
+ name to keep them apart. 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 ``/``
and
+ groups it under its bundle when more than one bundle is present.
+
+ Stale Dags are left out to match the Dag list, which hides them by
default: keeping
+ them would surface folders (or whole bundles, once they stop being parsed)
that
+ select down to an empty list.
+ """
+ query = readable_dags_filter.to_orm(
+ select(DagModel.bundle_name, DagModel.relative_fileloc)
+ .where(DagModel.relative_fileloc.is_not(None), DagModel.is_stale ==
false())
+ .distinct()
+ )
+ folders: set[tuple[str, str]] = set()
+ for bundle_name, relative_fileloc in session.execute(query):
+ parent = PurePosixPath(relative_fileloc).parent
+ if str(parent) != ".":
+ folders.add((bundle_name, str(parent)))
+
+ sorted_folders = sorted(folders)
+ return DagFolderCollectionResponse(
+ folders=[
+ DagFolderResponse(bundle_name=bundle_name, folder=folder)
+ for bundle_name, folder in sorted_folders
+ ],
+ total_entries=len(sorted_folders),
+ )
+
+
@dags_router.get(
"/{dag_id}/latest_run",
responses=create_openapi_http_exception_doc(
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 60c05efbc48..4385e089895 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -410,7 +410,7 @@ export const UseDagServiceGetDagTagsKeyFn = ({ limit,
offset, orderBy, tagNamePa
export type DagServiceGetDagsUiDefaultResponse = Awaited<ReturnType<typeof
DagService.getDagsUi>>;
export type DagServiceGetDagsUiQueryResult<TData =
DagServiceGetDagsUiDefaultResponse, TError = unknown> = UseQueryResult<TData,
TError>;
export const useDagServiceGetDagsUiKey = "DagServiceGetDagsUi";
-export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }: {
+export const UseDagServiceGetDagsUiKeyFn = ({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams,
timetableType }: {
assetDependency?: string;
bundleName?: string;
bundleVersion?: string;
@@ -433,12 +433,13 @@ export const UseDagServiceGetDagsUiKeyFn = ({
assetDependency, bundleName, bundl
orderBy?: string[];
owners?: string[];
paused?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: string[];
tagsMatchMode?: "any" | "all";
teams?: string[];
timetableType?: string[];
-} = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagsUiKey, ...(queryKey
?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern,
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds,
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags,
tagsMatchMode, teams, timetableType }])];
+} = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagsUiKey, ...(queryKey
?? [{ assetDependency, bundleName, bundleVersion, dagDisplayNamePattern,
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds,
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix,
schedulingState, tags, tagsMatchMode, teams, timetableType }])];
export type DagServiceGetDagTimetableTypesUiDefaultResponse =
Awaited<ReturnType<typeof DagService.getDagTimetableTypesUi>>;
export type DagServiceGetDagTimetableTypesUiQueryResult<TData =
DagServiceGetDagTimetableTypesUiDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useDagServiceGetDagTimetableTypesUiKey =
"DagServiceGetDagTimetableTypesUi";
@@ -447,6 +448,10 @@ export const UseDagServiceGetDagTimetableTypesUiKeyFn = ({
limit, offset, timeta
offset?: number;
timetableTypePrefixPattern?: string;
} = {}, queryKey?: Array<unknown>) => [useDagServiceGetDagTimetableTypesUiKey,
...(queryKey ?? [{ limit, offset, timetableTypePrefixPattern }])];
+export type DagServiceGetDagFoldersDefaultResponse = Awaited<ReturnType<typeof
DagService.getDagFolders>>;
+export type DagServiceGetDagFoldersQueryResult<TData =
DagServiceGetDagFoldersDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
+export const useDagServiceGetDagFoldersKey = "DagServiceGetDagFolders";
+export const UseDagServiceGetDagFoldersKeyFn = (queryKey?: Array<unknown>) =>
[useDagServiceGetDagFoldersKey, ...(queryKey ?? [])];
export type DagServiceGetLatestRunInfoDefaultResponse =
Awaited<ReturnType<typeof DagService.getLatestRunInfo>>;
export type DagServiceGetLatestRunInfoQueryResult<TData =
DagServiceGetLatestRunInfoDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
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 25b66d11174..8ad3eccf42a 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -828,6 +828,7 @@ export const ensureUseDagServiceGetDagTagsData =
(queryClient: QueryClient, { li
* @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls
within the last given hours. Ignored unless dag_run_state is set.
* @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, last_run_run_after`
* @param data.isFavorite
* @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,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, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags,
tagsMatchMode, teams, timetableType }: {
+export const ensureUseDagServiceGetDagsUiData = (queryClient: QueryClient, {
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern,
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds,
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix,
schedulingState, tags, tagsMatchMode, teams, timetableType }: {
assetDependency?: string;
bundleName?: string;
bundleVersion?: string;
@@ -860,12 +861,13 @@ export const ensureUseDagServiceGetDagsUiData =
(queryClient: QueryClient, { ass
orderBy?: string[];
owners?: string[];
paused?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: string[];
tagsMatchMode?: "any" | "all";
teams?: string[];
timetableType?: string[];
-} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }), queryFn:
() => [...]
+} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams,
timetableT [...]
/**
* Get Dag Timetable Types
* Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const ensureUseDagServiceGetDagTimetableTypesUiData
= (queryClient: Query
timetableTypePrefixPattern?: string;
} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset,
timetableTypePrefixPattern }), queryFn: () =>
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern
}) });
/**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. 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 ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default:
keeping
+* them would surface folders (or whole bundles, once they stop being parsed)
that
+* select down to an empty list.
+* @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.
* @param data The data for the request.
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 0f8b2db2660..65c7f1cde56 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -828,6 +828,7 @@ export const prefetchUseDagServiceGetDagTags =
(queryClient: QueryClient, { limi
* @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls
within the last given hours. Ignored unless dag_run_state is set.
* @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, last_run_run_after`
* @param data.isFavorite
* @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,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, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, schedulingState, tags,
tagsMatchMode, teams, timetableType }: {
+export const prefetchUseDagServiceGetDagsUi = (queryClient: QueryClient, {
assetDependency, bundleName, bundleVersion, dagDisplayNamePattern,
dagDisplayNamePrefixPattern, dagIdPattern, dagIdPrefixPattern, dagIds,
dagRunsLimit, dagRunState, dagRunStateWithinHours, excludeStale,
hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFilelocPrefix,
schedulingState, tags, tagsMatchMode, teams, timetableType }: {
assetDependency?: string;
bundleName?: string;
bundleVersion?: string;
@@ -860,12 +861,13 @@ export const prefetchUseDagServiceGetDagsUi =
(queryClient: QueryClient, { asset
orderBy?: string[];
owners?: string[];
paused?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: string[];
tagsMatchMode?: "any" | "all";
teams?: string[];
timetableType?: string[];
-} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, schedulingState, tags, tagsMatchMode, teams, timetableType }), queryFn:
() => Da [...]
+} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owners,
paused, relativeFilelocPrefix, schedulingState, tags, tagsMatchMode, teams,
timetableTyp [...]
/**
* Get Dag Timetable Types
* Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const prefetchUseDagServiceGetDagTimetableTypesUi =
(queryClient: QueryCl
timetableTypePrefixPattern?: string;
} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset,
timetableTypePrefixPattern }), queryFn: () =>
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern
}) });
/**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. 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 ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default:
keeping
+* them would surface folders (or whole bundles, once they stop being parsed)
that
+* select down to an empty list.
+* @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.
* @param data The data for the request.
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 d50d14d7c1b..d5dc9b17b5f 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -828,6 +828,7 @@ export const useDagServiceGetDagTags = <TData =
Common.DagServiceGetDagTagsDefau
* @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls
within the last given hours. Ignored unless dag_run_state is set.
* @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, last_run_run_after`
* @param data.isFavorite
* @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const useDagServiceGetDagTags = <TData =
Common.DagServiceGetDagTagsDefau
* @returns DAGWithLatestDagRunsCollectionResponse Successful Response
* @throws ApiError
*/
-export const useDagServiceGetDagsUi = <TData =
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion,
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern,
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours,
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, scheduling [...]
+export const useDagServiceGetDagsUi = <TData =
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion,
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern,
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours,
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, relativeFi [...]
assetDependency?: string;
bundleName?: string;
bundleVersion?: string;
@@ -860,12 +861,13 @@ export const useDagServiceGetDagsUi = <TData =
Common.DagServiceGetDagsUiDefault
orderBy?: string[];
owners?: string[];
paused?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: string[];
tagsMatchMode?: "any" | "all";
teams?: string[];
timetableType?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owne
[...]
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, orderBy, owne
[...]
/**
* Get Dag Timetable Types
* Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const useDagServiceGetDagTimetableTypesUi = <TData
= Common.DagServiceGet
timetableTypePrefixPattern?: string;
} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset,
timetableTypePrefixPattern }, queryKey), queryFn: () =>
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern
}) as TData, ...options });
/**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. 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 ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default:
keeping
+* them would surface folders (or whole bundles, once they stop being parsed)
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagFolders = <TData =
Common.DagServiceGetDagFoldersDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?:
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) =>
useQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () =>
DagService.getDagFolders() as TData, ...options });
+/**
* Get Latest Run Info
* Get latest run.
* @param data The data for the request.
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 b0979f8e663..24b2ae2a7e0 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -828,6 +828,7 @@ export const useDagServiceGetDagTagsSuspense = <TData =
Common.DagServiceGetDagT
* @param data.dagRunStateWithinHours Only match DagRuns whose run_after falls
within the last given hours. Ignored unless dag_run_state is set.
* @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, last_run_run_after`
* @param data.isFavorite
* @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -837,7 +838,7 @@ export const useDagServiceGetDagTagsSuspense = <TData =
Common.DagServiceGetDagT
* @returns DAGWithLatestDagRunsCollectionResponse Successful Response
* @throws ApiError
*/
-export const useDagServiceGetDagsUiSuspense = <TData =
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion,
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern,
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours,
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, sc [...]
+export const useDagServiceGetDagsUiSuspense = <TData =
Common.DagServiceGetDagsUiDefaultResponse, TError = unknown, TQueryKey extends
Array<unknown> = unknown[]>({ assetDependency, bundleName, bundleVersion,
dagDisplayNamePattern, dagDisplayNamePrefixPattern, dagIdPattern,
dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState, dagRunStateWithinHours,
excludeStale, hasAssetSchedule, hasImportErrors, hasPendingActions, isFavorite,
lastDagRunState, limit, offset, orderBy, owners, paused, re [...]
assetDependency?: string;
bundleName?: string;
bundleVersion?: string;
@@ -860,12 +861,13 @@ export const useDagServiceGetDagsUiSuspense = <TData =
Common.DagServiceGetDagsU
orderBy?: string[];
owners?: string[];
paused?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: string[];
tagsMatchMode?: "any" | "all";
teams?: string[];
timetableType?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, order [...]
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagsUiKeyFn({ assetDependency, bundleName,
bundleVersion, dagDisplayNamePattern, dagDisplayNamePrefixPattern,
dagIdPattern, dagIdPrefixPattern, dagIds, dagRunsLimit, dagRunState,
dagRunStateWithinHours, excludeStale, hasAssetSchedule, hasImportErrors,
hasPendingActions, isFavorite, lastDagRunState, limit, offset, order [...]
/**
* Get Dag Timetable Types
* Get timetable types used by readable Dags.
@@ -882,6 +884,25 @@ export const useDagServiceGetDagTimetableTypesUiSuspense =
<TData = Common.DagSe
timetableTypePrefixPattern?: string;
} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagTimetableTypesUiKeyFn({ limit, offset,
timetableTypePrefixPattern }, queryKey), queryFn: () =>
DagService.getDagTimetableTypesUi({ limit, offset, timetableTypePrefixPattern
}) as TData, ...options });
/**
+* Get Dag Folders
+* Get the distinct folders the readable Dags live in, scoped to their bundle.
+*
+* A folder is the directory part of a Dag's ``relative_fileloc`` (relative to
its
+* bundle root). Because ``relative_fileloc`` is relative to each bundle, the
same
+* path can exist in several bundles, so every folder is paired with its bundle
+* name to keep them apart. 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 ``/`` and
+* groups it under its bundle when more than one bundle is present.
+*
+* Stale Dags are left out to match the Dag list, which hides them by default:
keeping
+* them would surface folders (or whole bundles, once they stop being parsed)
that
+* select down to an empty list.
+* @returns DagFolderCollectionResponse Successful Response
+* @throws ApiError
+*/
+export const useDagServiceGetDagFoldersSuspense = <TData =
Common.DagServiceGetDagFoldersDefaultResponse, TError = unknown, TQueryKey
extends Array<unknown> = unknown[]>(queryKey?: TQueryKey, options?:
Omit<UseQueryOptions<TData, TError>, "queryKey" | "queryFn">) =>
useSuspenseQuery<TData, TError>({ queryKey:
Common.UseDagServiceGetDagFoldersKeyFn(queryKey), queryFn: () =>
DagService.getDagFolders() as TData, ...options });
+/**
* Get Latest Run Info
* Get latest run.
* @param data The data for the request.
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 d5943c54077..4df2dabe060 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
@@ -10553,6 +10553,43 @@ export const $DAGsRunStateCountsCollectionResponse = {
description: 'Collection of per-Dag DagRun-state counts for the Dag list
page.'
} as const;
+export const $DagFolderCollectionResponse = {
+ properties: {
+ folders: {
+ items: {
+ '$ref': '#/components/schemas/DagFolderResponse'
+ },
+ 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, each scoped to the
bundle it belongs to.'
+} as const;
+
+export const $DagFolderResponse = {
+ properties: {
+ bundle_name: {
+ type: 'string',
+ title: 'Bundle Name'
+ },
+ folder: {
+ type: 'string',
+ title: 'Folder'
+ }
+ },
+ type: 'object',
+ required: ['bundle_name', 'folder'],
+ title: 'DagFolderResponse',
+ description: 'A distinct Dag folder (directory of ``relative_fileloc``)
within a bundle.'
+} 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 37f6155ee3c..97ead7a6f40 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,
Dele [...]
+import type { GetAssetsData, GetAssetsResponse, GetAssetAliasesData,
GetAssetAliasesResponse, GetAssetAliasData, GetAssetAliasResponse,
GetAssetEventsData, GetAssetEventsResponse, CreateAssetEventData,
CreateAssetEventResponse, MaterializeAssetData, MaterializeAssetResponse,
GetAssetQueuedEventsData, GetAssetQueuedEventsResponse,
DeleteAssetQueuedEventsData, DeleteAssetQueuedEventsResponse, GetAssetData,
GetAssetResponse, GetDagAssetQueuedEventsData, GetDagAssetQueuedEventsResponse,
Dele [...]
export class AssetService {
/**
@@ -2183,6 +2183,7 @@ export class DagService {
* @param data.dagRunStateWithinHours Only match DagRuns whose run_after
falls within the last given hours. Ignored unless dag_run_state is set.
* @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, last_run_run_after`
* @param data.isFavorite
* @param data.hasAssetSchedule Filter Dags with asset-based scheduling
@@ -2218,6 +2219,7 @@ export class DagService {
dag_run_state_within_hours: data.dagRunStateWithinHours,
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,
@@ -2256,6 +2258,31 @@ export class DagService {
});
}
+ /**
+ * Get Dag Folders
+ * Get the distinct folders the readable Dags live in, scoped to their
bundle.
+ *
+ * A folder is the directory part of a Dag's ``relative_fileloc``
(relative to its
+ * bundle root). Because ``relative_fileloc`` is relative to each bundle,
the same
+ * path can exist in several bundles, so every folder is paired with its
bundle
+ * name to keep them apart. 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
``/`` and
+ * groups it under its bundle when more than one bundle is present.
+ *
+ * Stale Dags are left out to match the Dag list, which hides them by
default: keeping
+ * them would surface folders (or whole bundles, once they stop being
parsed) that
+ * select down to an empty list.
+ * @returns DagFolderCollectionResponse Successful Response
+ * @throws ApiError
+ */
+ public static getDagFolders(): CancelablePromise<GetDagFoldersResponse> {
+ 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 b1250bdbc04..5a5d86c4b58 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
@@ -2677,6 +2677,22 @@ export type DAGsRunStateCountsCollectionResponse = {
state_count_limit: number;
};
+/**
+ * Collection of distinct Dag folders, each scoped to the bundle it belongs to.
+ */
+export type DagFolderCollectionResponse = {
+ folders: Array<DagFolderResponse>;
+ total_entries: number;
+};
+
+/**
+ * A distinct Dag folder (directory of ``relative_fileloc``) within a bundle.
+ */
+export type DagFolderResponse = {
+ bundle_name: string;
+ folder: string;
+};
+
/**
* DAG Run statistics serializer for responses.
*/
@@ -3944,6 +3960,10 @@ 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;
schedulingState?: DagSchedulingState | null;
tags?: Array<(string)>;
tagsMatchMode?: 'any' | 'all' | null;
@@ -3964,6 +3984,8 @@ export type GetDagTimetableTypesUiData = {
export type GetDagTimetableTypesUiResponse =
DagTimetableTypeCollectionResponse;
+export type GetDagFoldersResponse = DagFolderCollectionResponse;
+
export type GetLatestRunInfoData = {
dagId: string;
};
@@ -6992,6 +7014,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 595ce204e69..f7d5e39d3d1 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
@@ -31,6 +31,13 @@
"runStatePlaceholder": "Select run state…",
"timetableType": "Timetable type"
},
+ "folders": {
+ "all": "All Dags",
+ "empty": "No folders",
+ "hide": "Hide folders",
+ "show": "Show folders",
+ "title": "Folders"
+ },
"ownerLink": "Owner link for {{owner}}",
"runAndTaskActions": {
"affectedTasks": {
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 43b814aa0d3..0b0ec8f4e92 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -21,6 +21,7 @@
export const TIMEZONE_KEY = "timezone";
export const DEFAULT_DAG_VIEW_KEY = "default_dag_view";
export const DAGS_LIST_DISPLAY_KEY = "dags_list_display";
+export const DAGS_LIST_SHOW_FOLDERS_KEY = "dags_list_show_folders";
export const CALENDAR_GRANULARITY_KEY = "calendar-granularity";
export const CALENDAR_VIEW_MODE_KEY = "calendar-view-mode";
export const LOG_WRAP_KEY = "log_wrap";
diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 195a8eccc1c..74453e5a44e 100644
--- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts
+++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
@@ -31,7 +31,9 @@ export enum SearchParamsKeys {
CREATED_AT_LTE = "created_at_lte",
CREATED_AT_RANGE = "created_at_range",
CURSOR = "cursor",
+ DAG_BUNDLE = "dag_bundle",
DAG_DISPLAY_NAME_PATTERN = "dag_display_name_pattern",
+ DAG_FOLDER = "dag_folder",
DAG_ID = "dag_id",
DAG_ID_PATTERN = "dag_id_pattern",
DAG_RUN_STATE = "dag_run_state",
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 00000000000..c85b83d5d27
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.test.tsx
@@ -0,0 +1,323 @@
+/*!
+ * 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 type { DagFolderResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { DagFolderTree } from "./DagFolderTree";
+
+const SINGLE_BUNDLE: Array<DagFolderResponse> = [
+ { bundle_name: "dags-folder", folder: "team_a/etl" },
+ { bundle_name: "dags-folder", folder: "team_a/report" },
+ { bundle_name: "dags-folder", folder: "team_b/ml" },
+];
+
+// Both bundles reuse the ``team_a/etl`` path to check they stay separate.
+const MULTI_BUNDLE: Array<DagFolderResponse> = [
+ { bundle_name: "analytics", folder: "team_a/etl" },
+ { bundle_name: "ml", folder: "team_a/etl" },
+ { bundle_name: "ml", folder: "features" },
+];
+
+// Tree nodes are identified by their own label, ignoring the labels of nested
children.
+// ``hidden`` is needed because collapsed branches are hidden from the
accessibility tree.
+const nodes = () => screen.getAllByRole("treeitem", { hidden: true });
+
+const node = (label: string) =>
+ nodes().find(
+ (item) => item.querySelector('[data-part="branch-text"],
[data-part="item-text"]')?.textContent === label,
+ );
+
+describe("DagFolderTree (single bundle)", () => {
+ it("renders the top-level folders and an 'All Dags' entry, without a bundle
level", () => {
+ render(
+ <DagFolderTree
+ folders={SINGLE_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(screen.getByRole("button", { name: "folders.all"
})).toBeInTheDocument();
+ expect(node("team_a")).toBeDefined();
+ expect(node("team_b")).toBeDefined();
+ expect(screen.queryByText("dags-folder")).not.toBeInTheDocument();
+ });
+
+ it("renders an accessible tree instead of click handlers on plain elements",
() => {
+ render(
+ <DagFolderTree
+ folders={SINGLE_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(screen.getByRole("tree")).toBeInTheDocument();
+ expect(nodes().length).toBeGreaterThan(0);
+ // Expandable nodes expose their state, and the tree is keyboard reachable.
+ expect(node("team_a")).toHaveAttribute("aria-expanded", "false");
+ expect(node("team_a")).toHaveAttribute("aria-level", "1");
+ expect(screen.getAllByRole("button").length).toBeGreaterThan(0);
+ });
+
+ it("reserves the expand indicator width on leaves so siblings line up", ()
=> {
+ render(
+ <DagFolderTree
+ folders={[
+ { bundle_name: "dags-folder", folder: "reports" },
+ { bundle_name: "dags-folder", folder: "team_a/etl" },
+ ]}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ // ``reports`` has no children, so it renders a hidden placeholder where
``team_a`` shows
+ // its expand indicator; without it the two siblings would not be indented
the same way.
+ const leaf = node("reports");
+
+ expect(leaf?.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
+
expect(node("team_a")?.querySelector('[data-part="branch-indicator"]')).toBeInTheDocument();
+ });
+
+ it("truncates long names so the selected row highlight is not cut off", ()
=> {
+ const longName = "a-very-long-folder-name-that-does-not-fit-in-the-panel";
+
+ render(
+ <DagFolderTree
+ folders={[{ bundle_name: "dags-folder", folder: longName }]}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={longName}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ // The row spans the panel and the label truncates, so the highlight
covers the whole row.
+ // The full name stays reachable through the native tooltip.
+ const label = screen.getByTitle(longName);
+
+ expect(label).toHaveTextContent(longName);
+ expect(node(longName)).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("keeps sub-folders hidden until their parent is expanded", () => {
+ render(
+ <DagFolderTree
+ folders={SINGLE_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(screen.getByText("etl")).not.toBeVisible();
+ });
+
+ it("expands down to the selected folder once folders finish loading", () => {
+ // Folders are fetched asynchronously, so the first render has nothing to
expand. Landing on a
+ // URL that already points at a nested folder must still open the tree
once they arrive.
+ const props = {
+ onSelectFolder: vi.fn(),
+ selectedBundle: undefined,
+ selectedFolder: "team_a/etl",
+ };
+ const { rerender } = render(<DagFolderTree folders={[]} {...props} />, {
wrapper: BaseWrapper });
+
+ expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+
+ rerender(<DagFolderTree folders={SINGLE_BUNDLE} {...props} />);
+
+ expect(node("team_a")).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("etl")).toBeVisible();
+ expect(node("etl")).toHaveAttribute("aria-selected", "true");
+ });
+
+ it("expands the ancestors of the selected folder and marks it as selected",
() => {
+ render(
+ <DagFolderTree
+ folders={SINGLE_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder="team_a/etl"
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(node("team_a")).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByText("etl")).toBeVisible();
+ expect(node("etl")).toHaveAttribute("aria-selected", "true");
+ expect(node("team_b")).toHaveAttribute("aria-selected", "false");
+ });
+
+ it("clears the selection when 'All Dags' is clicked", () => {
+ const onSelectFolder = vi.fn();
+
+ render(
+ <DagFolderTree
+ folders={SINGLE_BUNDLE}
+ onSelectFolder={onSelectFolder}
+ selectedBundle={undefined}
+ selectedFolder="team_b/ml"
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "folders.all" }));
+
+ expect(onSelectFolder).toHaveBeenCalledWith({ bundleName: undefined,
folder: undefined });
+ });
+
+ it("shows an empty message and no tree when there are no folders", () => {
+ render(
+ <DagFolderTree
+ folders={[]}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(screen.getByText("folders.empty")).toBeInTheDocument();
+ expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+ });
+
+ it("renders skeletons while loading", () => {
+ render(
+ <DagFolderTree
+ folders={[]}
+ isLoading
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(screen.queryByRole("tree")).not.toBeInTheDocument();
+ expect(screen.queryByText("folders.empty")).not.toBeInTheDocument();
+ });
+});
+
+describe("DagFolderTree (multiple bundles)", () => {
+ it("renders bundles as the top level with their folders nested underneath",
() => {
+ render(
+ <DagFolderTree
+ folders={MULTI_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(node("analytics")).toHaveAttribute("aria-level", "1");
+ expect(node("ml")).toHaveAttribute("aria-level", "1");
+ // Folders live below their bundle and stay hidden until it is expanded.
+ expect(screen.getByText("features")).not.toBeVisible();
+ expect(node("features")).toHaveAttribute("aria-level", "2");
+ });
+
+ it("keeps the same folder path separate under each bundle", () => {
+ render(
+ <DagFolderTree
+ folders={MULTI_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle={undefined}
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ // ``team_a`` exists in both bundles, so it must appear once per bundle.
+ const teamANodes = nodes().filter(
+ (item) => item.querySelector('[data-part="branch-text"]')?.textContent
=== "team_a",
+ );
+
+ expect(teamANodes).toHaveLength(2);
+ expect(teamANodes.map((item) => item.getAttribute("data-value"))).toEqual([
+ "folder:analytics:team_a",
+ "folder:ml:team_a",
+ ]);
+ });
+
+ it("expands the selected bundle down to the selected folder", () => {
+ render(
+ <DagFolderTree
+ folders={MULTI_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle="ml"
+ selectedFolder="team_a/etl"
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(node("ml")).toHaveAttribute("aria-expanded", "true");
+
+ // ``etl`` exists in both bundles; only the one under ``ml`` is selected.
+ const selected = nodes().filter((item) =>
item.getAttribute("aria-selected") === "true");
+
+ expect(selected.map((item) =>
item.getAttribute("data-value"))).toEqual(["folder:ml:team_a/etl"]);
+ });
+
+ it("expands down to the selected folder once folders finish loading", () => {
+ const props = {
+ onSelectFolder: vi.fn(),
+ selectedBundle: "ml",
+ selectedFolder: "team_a/etl",
+ };
+ const { rerender } = render(<DagFolderTree folders={[]} {...props} />, {
wrapper: BaseWrapper });
+
+ rerender(<DagFolderTree folders={MULTI_BUNDLE} {...props} />);
+
+ expect(node("ml")).toHaveAttribute("aria-expanded", "true");
+ // ``etl`` exists in both bundles; the one under ``ml`` is the one
revealed and selected.
+ const selected = nodes().filter((item) =>
item.getAttribute("aria-selected") === "true");
+
+ expect(selected.map((item) =>
item.getAttribute("data-value"))).toEqual(["folder:ml:team_a/etl"]);
+ });
+
+ it("marks a bundle as selected when only the bundle is selected", () => {
+ render(
+ <DagFolderTree
+ folders={MULTI_BUNDLE}
+ onSelectFolder={vi.fn()}
+ selectedBundle="analytics"
+ selectedFolder={undefined}
+ />,
+ { wrapper: BaseWrapper },
+ );
+
+ expect(node("analytics")).toHaveAttribute("aria-selected", "true");
+ expect(node("ml")).toHaveAttribute("aria-selected", "false");
+ });
+});
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 00000000000..f3790657dba
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/DagFolderTree.tsx
@@ -0,0 +1,216 @@
+/*!
+ * 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 { useState } from "react";
+
+import {
+ Box,
+ Button,
+ createTreeCollection,
+ Heading,
+ Skeleton,
+ TreeView,
+ type TreeViewNodeRenderProps,
+ VStack,
+} from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiBox, FiChevronRight, FiFolder } from "react-icons/fi";
+
+import type { DagFolderResponse } from "openapi/requests/types.gen";
+
+import {
+ bundleNodeValue,
+ buildFolderTreeNodes,
+ folderNodeValue,
+ type FolderTreeNode,
+} from "./buildFolderTree";
+
+export type FolderSelection = {
+ readonly bundleName: string | undefined;
+ readonly folder: string | undefined;
+};
+
+type Props = {
+ readonly folders: ReadonlyArray<DagFolderResponse>;
+ readonly isLoading?: boolean;
+ readonly onSelectFolder: (selection: FolderSelection) => void;
+ readonly selectedBundle: string | undefined;
+ readonly selectedFolder: string | undefined;
+};
+
+const NodeIcon = ({ node }: { readonly node: FolderTreeNode }) => (
+ <Box as="span" color="fg.muted" flexShrink={0}>
+ {node.folder === undefined ? <FiBox /> : <FiFolder />}
+ </Box>
+);
+
+// Rows span the full panel width and long names are truncated, so the
selected background always
+// covers the whole row instead of stopping where the text overflows. The full
name stays available
+// as a native tooltip.
+const renderNode = (node: FolderTreeNode, isBranch: boolean) =>
+ isBranch ? (
+ <TreeView.BranchControl width="100%">
+ <TreeView.BranchIndicator flexShrink={0}>
+ <FiChevronRight />
+ </TreeView.BranchIndicator>
+ <NodeIcon node={node} />
+ <TreeView.BranchText minWidth={0} title={node.label} truncate>
+ {node.label}
+ </TreeView.BranchText>
+ </TreeView.BranchControl>
+ ) : (
+ <TreeView.Item width="100%">
+ {/* Reserve the width of the expand indicator so leaves line up with
their sibling folders. */}
+ <Box aria-hidden as="span" flexShrink={0} visibility="hidden">
+ <FiChevronRight />
+ </Box>
+ <NodeIcon node={node} />
+ <TreeView.ItemText minWidth={0} title={node.label} truncate>
+ {node.label}
+ </TreeView.ItemText>
+ </TreeView.Item>
+ );
+
+// Ancestor folders of the selection, so the tree opens far enough to reveal
it.
+const expandedForSelection = (
+ bundleName: string | undefined,
+ folder: string | undefined,
+ isMultiBundle: boolean,
+): Array<string> => {
+ const values: Array<string> = [];
+
+ if (isMultiBundle && bundleName !== undefined) {
+ values.push(bundleNodeValue(bundleName));
+ }
+
+ if (folder !== undefined && folder !== "" && bundleName !== undefined) {
+ const segments = folder.split("/");
+
+ values.push(
+ ...segments.map((_, index) => folderNodeValue(bundleName,
segments.slice(0, index + 1).join("/"))),
+ );
+ }
+
+ return values;
+};
+
+export const DagFolderTree = ({
+ folders,
+ isLoading = false,
+ onSelectFolder,
+ selectedBundle,
+ selectedFolder,
+}: Props) => {
+ const { t: translate } = useTranslation("dags");
+ const nodes = buildFolderTreeNodes(folders);
+ // Bundle nodes are only emitted when there is more than one bundle.
+ const isMultiBundle = nodes.some((node) => node.folder === undefined);
+ // Node values always carry a bundle; with a single bundle the URL omits it,
so fall back to
+ // the only bundle present to keep selection and expansion pointing at the
right nodes.
+ const nodeBundle = isMultiBundle ? selectedBundle : nodes[0]?.bundleName;
+
+ const [expandedValue, setExpandedValue] = useState<Array<string>>(() =>
+ expandedForSelection(nodeBundle, selectedFolder, isMultiBundle),
+ );
+
+ // Folders arrive asynchronously, so the first render has no nodes to expand
yet: re-apply the
+ // auto-expansion whenever the selection resolves to different nodes (on
load, or when the user
+ // lands on a URL that already points at a nested folder). Manual expansions
are kept.
+ const selectionKey = `${nodeBundle ?? ""}|${selectedFolder ??
""}|${isMultiBundle}`;
+ const [appliedSelectionKey, setAppliedSelectionKey] = useState(selectionKey);
+
+ if (appliedSelectionKey !== selectionKey) {
+ setAppliedSelectionKey(selectionKey);
+ setExpandedValue((previous) => [
+ ...new Set([...previous, ...expandedForSelection(nodeBundle,
selectedFolder, isMultiBundle)]),
+ ]);
+ }
+
+ const collection = createTreeCollection<FolderTreeNode>({
+ rootNode: { bundleName: "", children: nodes, label: "", value: "__root__"
},
+ });
+
+ const selectedValue =
+ selectedFolder !== undefined && nodeBundle !== undefined
+ ? [folderNodeValue(nodeBundle, selectedFolder)]
+ : selectedBundle === undefined
+ ? []
+ : [bundleNodeValue(selectedBundle)];
+
+ const isAllSelected = selectedBundle === undefined && selectedFolder ===
undefined;
+
+ return (
+ <Box minWidth={0} width="100%">
+ <Heading mb={2} size="sm">
+ {translate("folders.title")}
+ </Heading>
+ {isLoading ? (
+ <VStack align="stretch" gap={2}>
+ <Skeleton height="24px" />
+ <Skeleton height="24px" />
+ <Skeleton height="24px" />
+ </VStack>
+ ) : (
+ <Box>
+ <Button
+ bg={isAllSelected ? "blue.subtle" : undefined}
+ fontWeight={isAllSelected ? "bold" : "normal"}
+ justifyContent="flex-start"
+ onClick={() => onSelectFolder({ bundleName: undefined, folder:
undefined })}
+ size="sm"
+ variant="ghost"
+ width="100%"
+ >
+ {translate("folders.all")}
+ </Button>
+ {nodes.length === 0 ? (
+ <Box color="fg.muted" fontSize="sm" pl="4px" py={1}>
+ {translate("folders.empty")}
+ </Box>
+ ) : (
+ <TreeView.Root
+ collection={collection}
+ expandedValue={expandedValue}
+ onExpandedChange={(details) =>
setExpandedValue(details.expandedValue)}
+ onSelectionChange={(details) => {
+ const [value] = details.selectedValue;
+ const selectedNode = value === undefined ? undefined :
collection.findNode(value);
+
+ if (selectedNode !== undefined) {
+ onSelectFolder({ bundleName: selectedNode.bundleName,
folder: selectedNode.folder });
+ }
+ }}
+ selectedValue={selectedValue}
+ selectionMode="single"
+ size="sm"
+ >
+ <TreeView.Tree>
+ <TreeView.Node
+ indentGuide={<TreeView.BranchIndentGuide />}
+ render={({ node, nodeState }:
TreeViewNodeRenderProps<FolderTreeNode>) =>
+ renderNode(node, nodeState.isBranch)
+ }
+ />
+ </TreeView.Tree>
+ </TreeView.Root>
+ )}
+ </Box>
+ )}
+ </Box>
+ );
+};
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 00000000000..9521f977551
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.test.ts
@@ -0,0 +1,119 @@
+/*!
+ * 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, groupFoldersByBundle } 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"]);
+ });
+});
+
+describe("groupFoldersByBundle", () => {
+ it("returns an empty array for no folders", () => {
+ expect(groupFoldersByBundle([])).toEqual([]);
+ });
+
+ it("groups folders under their bundle and sorts bundles alphabetically", ()
=> {
+ const bundles = groupFoldersByBundle([
+ { bundle_name: "ml", folder: "features" },
+ { bundle_name: "analytics", folder: "team_a/etl" },
+ ]);
+
+ expect(bundles.map((bundle) => bundle.bundleName)).toEqual(["analytics",
"ml"]);
+ });
+
+ it("keeps the same folder path separate per bundle", () => {
+ const bundles = groupFoldersByBundle([
+ { bundle_name: "analytics", folder: "team_a/etl" },
+ { bundle_name: "ml", folder: "team_a/etl" },
+ ]);
+
+ const analytics = bundles.find((bundle) => bundle.bundleName ===
"analytics");
+ const ml = bundles.find((bundle) => bundle.bundleName === "ml");
+
+ expect(analytics?.tree[0]?.children[0]?.path).toBe("team_a/etl");
+ expect(ml?.tree[0]?.children[0]?.path).toBe("team_a/etl");
+ });
+
+ it("builds a full folder tree within each bundle", () => {
+ const bundles = groupFoldersByBundle([
+ { bundle_name: "b1", folder: "team_a/etl" },
+ { bundle_name: "b1", folder: "team_a/report" },
+ ]);
+
+ expect(bundles).toHaveLength(1);
+ expect(bundles[0]?.tree[0]?.path).toBe("team_a");
+ expect(bundles[0]?.tree[0]?.children.map((node) =>
node.name)).toEqual(["etl", "report"]);
+ });
+});
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 00000000000..9eeb29bb3a6
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagFolderTree/buildFolderTree.ts
@@ -0,0 +1,152 @@
+/*!
+ * 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<FolderNode>;
+ /** 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<string>):
Array<FolderNode> => {
+ type MutableNode = { children: Map<string, MutableNode>; name: string; path:
string };
+
+ const roots = new Map<string, MutableNode>();
+
+ 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<string, MutableNode>): Array<FolderNode> =>
+ [...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);
+};
+
+export type BundleFolders = {
+ /** The bundle these folders belong to. */
+ readonly bundleName: string;
+ /** Folder tree built from this bundle's folder paths. */
+ readonly tree: Array<FolderNode>;
+};
+
+/**
+ * Group folders by their bundle, then build a folder tree per bundle.
+ *
+ * Because ``relative_fileloc`` is relative to each bundle root, the same
folder path
+ * can exist in several bundles; grouping first keeps them separate. Bundles
are sorted
+ * alphabetically for stable rendering.
+ */
+export const groupFoldersByBundle = (
+ folders: ReadonlyArray<{ readonly bundle_name: string; readonly folder:
string }>,
+): Array<BundleFolders> => {
+ const pathsByBundle = new Map<string, Array<string>>();
+
+ for (const { bundle_name: bundleName, folder } of folders) {
+ const paths = pathsByBundle.get(bundleName) ?? [];
+
+ paths.push(folder);
+ pathsByBundle.set(bundleName, paths);
+ }
+
+ return [...pathsByBundle.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([bundleName, paths]) => ({ bundleName, tree: buildFolderTree(paths)
}));
+};
+
+export type FolderTreeNode = {
+ readonly bundleName: string;
+ readonly children?: Array<FolderTreeNode>;
+ /** Folder path within the bundle; undefined for a bundle node. */
+ readonly folder?: string;
+ readonly label: string;
+ /** Stable id used by the tree widget for selection and expansion. */
+ readonly value: string;
+};
+
+export const bundleNodeValue = (bundleName: string) => `bundle:${bundleName}`;
+export const folderNodeValue = (bundleName: string, folder: string) =>
`folder:${bundleName}:${folder}`;
+
+const toTreeNodes = (bundleName: string, nodes: ReadonlyArray<FolderNode>):
Array<FolderTreeNode> =>
+ nodes.map((node) => ({
+ bundleName,
+ ...(node.children.length > 0 ? { children: toTreeNodes(bundleName,
node.children) } : {}),
+ folder: node.path,
+ label: node.name,
+ value: folderNodeValue(bundleName, node.path),
+ }));
+
+/**
+ * Turn the folder payload into the node list the tree widget renders.
+ *
+ * With several bundles each bundle becomes a top-level node holding its own
folder tree, so
+ * identical folder paths in different bundles stay distinct. With a single
bundle the bundle
+ * level is dropped and folders are returned directly.
+ */
+export const buildFolderTreeNodes = (
+ folders: ReadonlyArray<{ readonly bundle_name: string; readonly folder:
string }>,
+): Array<FolderTreeNode> => {
+ const bundles = groupFoldersByBundle(folders);
+
+ if (bundles.length <= 1) {
+ const [bundle] = bundles;
+
+ return bundle === undefined ? [] : toTreeNodes(bundle.bundleName,
bundle.tree);
+ }
+
+ return bundles.map(({ bundleName, tree }) => ({
+ bundleName,
+ children: toTreeNodes(bundleName, tree),
+ label: bundleName,
+ value: bundleNodeValue(bundleName),
+ }));
+};
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 00000000000..6dd75a1d756
--- /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, type FolderSelection } 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 503cf5b0220..1e4ac9784cd 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Skeleton, VStack, type SelectValueChangeDetails } from
"@chakra-ui/react";
+import { Box, Flex, Skeleton, VStack, type SelectValueChangeDetails } from
"@chakra-ui/react";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
+import { FiSidebar } from "react-icons/fi";
import { useSearchParams } from "react-router-dom";
import { useLocalStorage } from "usehooks-ts";
@@ -28,7 +29,7 @@ import type {
DAGWithLatestDagRunsResponse,
} from "openapi/requests/types.gen";
-import { ActionBar, RouterLink } from "src/system-components";
+import { ActionBar, IconButton, RouterLink, Tooltip } from
"src/system-components";
import { DagsLayout } from "src/layouts/DagsLayout";
@@ -52,10 +53,11 @@ import { TeamName } from "src/components/TeamName";
import { TogglePause } from "src/components/TogglePause";
import { TriggerDAGButton } from "src/components/TriggerDag/TriggerDAGButton";
-import { DAGS_LIST_DISPLAY_KEY } from "src/constants/localStorage";
+import { DAGS_LIST_DISPLAY_KEY, DAGS_LIST_SHOW_FOLDERS_KEY } from
"src/constants/localStorage";
import { SearchParamsKeys, type SearchParamsKeysType } from
"src/constants/searchParams";
import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
import { useConfig } from "src/queries/useConfig";
+import { useDagFolders } from "src/queries/useDagFolders";
import { useDagRunStateCounts } from "src/queries/useDagRunStateCounts";
import { useDags } from "src/queries/useDags";
import { useDocumentTitle } from "src/utils";
@@ -64,6 +66,7 @@ import { DagImportErrors } from
"../Dashboard/Stats/DagImportErrors";
import BulkPauseDrainDagsButton from "./BulkPauseDrainDagsButton";
import BulkUnpauseDagsButton from "./BulkUnpauseDagsButton";
import { DagCard } from "./DagCard";
+import { DagFolderTree, type FolderSelection } from "./DagFolderTree";
import { DagRunStateCounts } from "./DagRunStateCounts";
import { DagTags } from "./DagTags";
import { DagsFilters } from "./DagsFilters";
@@ -244,6 +247,8 @@ const createColumns = (
];
const {
+ DAG_BUNDLE,
+ DAG_FOLDER,
DAG_RUN_STATE,
DAG_RUN_STATE_WITHIN_HOURS,
FAVORITE,
@@ -300,6 +305,16 @@ export const DagsList = () => {
const owners = searchParams.getAll(OWNERS).filter((value) => value !== "");
const teams = searchParams.getAll(TEAMS);
const timetableType = searchParams.getAll(TIMETABLE_TYPE).filter((value) =>
value !== "");
+ const selectedFolder = searchParams.get(DAG_FOLDER) ?? undefined;
+ const selectedBundle = searchParams.get(DAG_BUNDLE) ?? undefined;
+
+ const { folders, isLoading: foldersLoading } = useDagFolders();
+ const [showFolders, setShowFolders] =
useLocalStorage<boolean>(DAGS_LIST_SHOW_FOLDERS_KEY, true);
+ // Keep the panel out of the way for flat deployments (all Dags at the
bundle root). Still offer it
+ // while loading, or when a folder/bundle is selected so the user can always
navigate back to "All Dags".
+ const hasFolderTree =
+ foldersLoading || folders.length > 0 || Boolean(selectedFolder) ||
Boolean(selectedBundle);
+ const showFolderTree = hasFolderTree && showFolders;
const { setTableURLState, tableURLState } = useTableURLState();
@@ -325,6 +340,25 @@ export const DagsList = () => {
setSearchParams(searchParams);
};
+ const handleFolderChange = ({ bundleName, folder }: FolderSelection) => {
+ setTableURLState({
+ pagination: { ...pagination, pageIndex: 0 },
+ sorting,
+ });
+ if (folder === undefined || folder === "") {
+ searchParams.delete(DAG_FOLDER);
+ } else {
+ searchParams.set(DAG_FOLDER, folder);
+ }
+ if (bundleName === undefined || bundleName === "") {
+ searchParams.delete(DAG_BUNDLE);
+ } else {
+ searchParams.set(DAG_BUNDLE, bundleName);
+ }
+ searchParams.delete(OFFSET);
+ setSearchParams(searchParams);
+ };
+
// No param means no paused filtering. usePausedDefault seeds the configured
default on load,
// so an absent param after that is the user having removed the pill to see
every Dag.
let paused = undefined;
@@ -351,6 +385,7 @@ export const DagsList = () => {
const { data, error, isFetching, isLoading } = useDags({
advancedSearch: advancedSearch.enabled,
+ bundleName: selectedBundle,
dagDisplayNamePattern: Boolean(dagDisplayNamePattern) ?
dagDisplayNamePattern : undefined,
dagRunsLimit,
dagRunState,
@@ -363,6 +398,7 @@ export const DagsList = () => {
owners,
paused,
pendingHitl,
+ relativeFilelocPrefix: selectedFolder,
schedulingState: schedulingState ?? undefined,
tags: selectedTags,
tagsMatchMode: selectedMatchMode,
@@ -414,60 +450,98 @@ export const DagsList = () => {
return (
<DagsLayout>
- <Box pb={8}>
- <SelectionProvider
- allRowsSelected={allRowsSelected}
- onRowSelect={handleRowSelect}
- onSelectAll={handleSelectAll}
- selectedRows={selectedRows}
- >
- <DataTable
- cardDef={cardDef}
- columns={columns}
- data={data?.dags ?? []}
- displayMode={display}
- enableMultiSort
- errorMessage={<ErrorAlert error={error} />}
- filterActions={
- <VStack alignItems="flex-start" gap={2} w="100%">
- <SearchBar
- advancedSearch={advancedSearch}
- defaultValue={dagDisplayNamePattern}
- onChange={handleSearchChange}
- placeholder={translate("dags:search.dags")}
- />
- <DagsFilters />
- </VStack>
- }
- headingExtra={<DagImportErrors iconOnly />}
- initialState={tableURLState}
- isFetching={isFetching}
- isLoading={isLoading}
- modelName="common:dag"
- onDisplayToggleChange={handleDisplayToggleChange}
- onStateChange={setTableURLState}
- presentationActions={
- display === "card" ? (
- <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy[0]} />
- ) : undefined
- }
- showDisplayToggle
- skeletonCount={display === "card" ? 5 : undefined}
- total={totalEntries}
- />
- <ActionBar.Root closeOnInteractOutside={false} open={display ===
"table" && selectedRows.size > 0}>
- <ActionBar.Content>
- <ActionBar.SelectionTrigger>
- {selectedRows.size} {translate("selected")}
- </ActionBar.SelectionTrigger>
- <ActionBar.Separator />
- <BulkPauseDrainDagsButton deselectKeys={deselectKeys}
selectedDags={selectedDags} />
- <BulkUnpauseDagsButton deselectKeys={deselectKeys}
selectedDags={selectedDags} />
- <ActionBar.CloseTrigger onClick={clearSelections} />
- </ActionBar.Content>
- </ActionBar.Root>
- </SelectionProvider>
- </Box>
+ <Flex align="flex-start" gap={4} pb={8}>
+ {/* Only show the folder sidebar when there is something to navigate;
deployments with all
+ Dags at the bundle root would otherwise get an empty panel. */}
+ {showFolderTree ? (
+ <Box flexShrink={0} overflowY="auto" position="sticky" top={0}
width="280px">
+ <DagFolderTree
+ folders={folders}
+ isLoading={foldersLoading}
+ onSelectFolder={handleFolderChange}
+ selectedBundle={selectedBundle}
+ selectedFolder={selectedFolder}
+ />
+ </Box>
+ ) : undefined}
+ <Box flex={1} minWidth={0}>
+ <SelectionProvider
+ allRowsSelected={allRowsSelected}
+ onRowSelect={handleRowSelect}
+ onSelectAll={handleSelectAll}
+ selectedRows={selectedRows}
+ >
+ <DataTable
+ cardDef={cardDef}
+ columns={columns}
+ data={data?.dags ?? []}
+ displayMode={display}
+ enableMultiSort
+ errorMessage={<ErrorAlert error={error} />}
+ filterActions={
+ <VStack alignItems="flex-start" gap={2} w="100%">
+ <SearchBar
+ advancedSearch={advancedSearch}
+ defaultValue={dagDisplayNamePattern}
+ onChange={handleSearchChange}
+ placeholder={translate("dags:search.dags")}
+ />
+ <DagsFilters />
+ </VStack>
+ }
+ headingExtra={<DagImportErrors iconOnly />}
+ initialState={tableURLState}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ modelName="common:dag"
+ onDisplayToggleChange={handleDisplayToggleChange}
+ onStateChange={setTableURLState}
+ presentationActions={
+ hasFolderTree || display === "card" ? (
+ <>
+ {hasFolderTree ? (
+ <Tooltip
+ content={translate(showFolders ? "dags:folders.hide" :
"dags:folders.show")}
+ openDelay={200}
+ portalled
+ >
+ <IconButton
+ aria-label={translate(showFolders ?
"dags:folders.hide" : "dags:folders.show")}
+ onClick={() => setShowFolders(!showFolders)}
+ size="sm"
+ variant={showFolders ? "solid" : "outline"}
+ >
+ <FiSidebar />
+ </IconButton>
+ </Tooltip>
+ ) : undefined}
+ {display === "card" ? (
+ <SortSelect handleSortChange={handleSortChange}
orderBy={orderBy[0]} />
+ ) : undefined}
+ </>
+ ) : undefined
+ }
+ showDisplayToggle
+ skeletonCount={display === "card" ? 5 : undefined}
+ total={totalEntries}
+ />
+ <ActionBar.Root
+ closeOnInteractOutside={false}
+ open={display === "table" && selectedRows.size > 0}
+ >
+ <ActionBar.Content>
+ <ActionBar.SelectionTrigger>
+ {selectedRows.size} {translate("selected")}
+ </ActionBar.SelectionTrigger>
+ <ActionBar.Separator />
+ <BulkPauseDrainDagsButton deselectKeys={deselectKeys}
selectedDags={selectedDags} />
+ <BulkUnpauseDagsButton deselectKeys={deselectKeys}
selectedDags={selectedDags} />
+ <ActionBar.CloseTrigger onClick={clearSelections} />
+ </ActionBar.Content>
+ </ActionBar.Root>
+ </SelectionProvider>
+ </Box>
+ </Flex>
</DagsLayout>
);
};
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 00000000000..91866836390
--- /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 of all readable Dags, each paired with its
bundle.
+ *
+ * The list powers the folder navigation tree on the Dags page; the tree
hierarchy is
+ * reconstructed client-side by splitting each path on ``/`` and grouping by
bundle.
+ */
+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 a8d24eba136..df0cd6de996 100644
--- a/airflow-core/src/airflow/ui/src/queries/useDags.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useDags.tsx
@@ -23,6 +23,7 @@ import { isStatePending, useAutoRefresh } from "src/utils";
export const useDags = ({
advancedSearch = false,
+ bundleName,
dagDisplayNamePattern,
dagIdPattern,
dagRunsLimit,
@@ -37,6 +38,7 @@ export const useDags = ({
owners,
paused,
pendingHitl,
+ relativeFilelocPrefix,
schedulingState,
tags,
tagsMatchMode,
@@ -44,6 +46,7 @@ export const useDags = ({
timetableType,
}: {
advancedSearch?: boolean;
+ bundleName?: string;
dagDisplayNamePattern?: string;
dagIdPattern?: string;
dagRunsLimit: number;
@@ -58,6 +61,7 @@ export const useDags = ({
owners?: Array<string>;
paused?: boolean;
pendingHitl?: boolean;
+ relativeFilelocPrefix?: string;
schedulingState?: DagSchedulingState;
tags?: Array<string>;
tagsMatchMode?: "all" | "any";
@@ -71,6 +75,7 @@ export const useDags = ({
...(advancedSearch
? { dagDisplayNamePattern, dagIdPattern }
: { dagDisplayNamePrefixPattern: dagDisplayNamePattern,
dagIdPrefixPattern: dagIdPattern }),
+ bundleName,
dagRunsLimit,
dagRunState,
dagRunStateWithinHours,
@@ -83,6 +88,7 @@ export const useDags = ({
orderBy,
owners,
paused,
+ relativeFilelocPrefix,
schedulingState,
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 a15aac5f7a5..2321e6a4a95 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
@@ -31,6 +31,7 @@ from airflow.configuration import conf
from airflow.models import DagRun
from airflow.models.dag import DagModel, DagTag
from airflow.models.dag_favorite import DagFavorite
+from airflow.models.dagbundle import DagBundleModel
from airflow.models.hitl import HITLDetail
from airflow.sdk.timezone import utcnow
from airflow.utils.session import NEW_SESSION, provide_session
@@ -852,3 +853,185 @@ class TestGetDagRunStateCounts(TestPublicDagEndpoint):
def test_should_response_403(self, unauthorized_test_client):
response = unauthorized_test_client.get("/dags/run_state_counts",
params={"dag_ids": [DAG1_ID]})
assert response.status_code == 403
+
+
+# Maps dag_id -> (bundle_name, relative_fileloc). ``team_alpha`` proves a
folder
+# name is never matched as a prefix of another (``team_a`` must not catch it),
+# ``root_dag.py`` lives at the bundle root (no folder), and ``other_bundle``
reuses
+# the ``team_a/etl`` path to prove folders are kept separate per bundle.
+OTHER_BUNDLE = "other_bundle"
+FOLDER_DAGS = {
+ "folder_dag_a_etl_extract": ("dag_maker", "team_a/etl/extract.py"),
+ "folder_dag_a_etl_load": ("dag_maker", "team_a/etl/load.py"),
+ "folder_dag_a_report": ("dag_maker", "team_a/report.py"),
+ "folder_dag_b_ml_train": ("dag_maker", "team_b/ml/train.py"),
+ "folder_dag_alpha": ("dag_maker", "team_alpha/x.py"),
+ "folder_dag_root": ("dag_maker", "root_dag.py"),
+ "folder_dag_other_etl": (OTHER_BUNDLE, "team_a/etl/other.py"),
+}
+
+
+class TestDagFolders(TestPublicDagEndpoint):
+ @pytest.fixture(autouse=True)
+ @provide_session
+ def setup_folder_dags(self, *, session: Session = NEW_SESSION) -> None:
+ # The extra bundle must exist before its Dags are inserted (FK on
``bundle_name``).
+ session.merge(DagBundleModel(name=OTHER_BUNDLE))
+ session.flush()
+ for dag_id, (bundle_name, relative_fileloc) in FOLDER_DAGS.items():
+ session.add(
+ DagModel(
+ dag_id=dag_id,
+ bundle_name=bundle_name,
+ 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 (bundle, folder) pairs of every readable Dag, sorted.
Root-level
+ # Dags contribute no folder, and ``team_a/etl`` exists under both
bundles
+ # yet stays as two separate entries.
+ assert body["folders"] == [
+ {"bundle_name": "dag_maker", "folder": "team_a"},
+ {"bundle_name": "dag_maker", "folder": "team_a/etl"},
+ {"bundle_name": "dag_maker", "folder": "team_alpha"},
+ {"bundle_name": "dag_maker", "folder": "team_b/ml"},
+ {"bundle_name": OTHER_BUNDLE, "folder": "team_a/etl"},
+ ]
+ assert body["total_entries"] == 5
+
+ def test_get_dag_folders_query_count_does_not_scale_with_dags(self,
session, test_client):
+ """The folders endpoint must run a finite number of queries regardless
of how many Dags exist."""
+ with count_queries() as result:
+ response = test_client.get("/dags/folders")
+ assert response.status_code == 200
+ baseline = sum(result.values())
+
+ # Add many more Dags (in both new and existing folders); the query
count must not grow.
+ for i in range(50):
+ relative_fileloc = f"team_c/sub_{i}/dag_{i}.py"
+ session.add(
+ DagModel(
+ dag_id=f"folder_scale_dag_{i}",
+ bundle_name="dag_maker",
+ relative_fileloc=relative_fileloc,
+ fileloc=f"/tmp/{relative_fileloc}",
+ is_stale=False,
+ is_paused=False,
+ )
+ )
+ session.commit()
+ session.expire_all()
+
+ with count_queries() as result_after:
+ response = test_client.get("/dags/folders")
+ assert response.status_code == 200
+ assert sum(result_after.values()) == baseline
+
+ def test_get_dag_folders_excludes_stale_dags(self, session, test_client):
+ # A bundle that stops being parsed leaves its Dags stale; the Dag list
hides them by
+ # default, so their folders must not linger in the tree and select
down to nothing.
+ session.merge(DagBundleModel(name="retired_bundle"))
+ session.flush()
+ session.add(
+ DagModel(
+ dag_id="folder_dag_retired",
+ bundle_name="retired_bundle",
+ relative_fileloc="retired/old.py",
+ fileloc="/tmp/retired/old.py",
+ is_stale=True,
+ is_paused=False,
+ )
+ )
+ session.commit()
+
+ response = test_client.get("/dags/folders")
+ assert response.status_code == 200
+ folders = response.json()["folders"]
+ assert {"bundle_name": "retired_bundle", "folder": "retired"} not in
folders
+ # The live folders are still returned.
+ assert {"bundle_name": OTHER_BUNDLE, "folder": "team_a/etl"} in folders
+
+ 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",
+ "folder_dag_other_etl",
+ },
+ id="folder-with-subfolders",
+ ),
+ pytest.param(
+ "team_a/etl",
+ {"folder_dag_a_etl_extract", "folder_dag_a_etl_load",
"folder_dag_other_etl"},
+ id="nested-folder-across-bundles",
+ ),
+ pytest.param(
+ "team_a/etl/",
+ {"folder_dag_a_etl_extract", "folder_dag_a_etl_load",
"folder_dag_other_etl"},
+ 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):
+ # The folder filter matches on path only; bundle scoping is layered on
via the
+ # existing ``bundle_name`` filter (see
test_folder_filter_scoped_by_bundle).
+ 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
+
+ @pytest.mark.parametrize(
+ ("bundle_name", "expected_dag_ids"),
+ [
+ pytest.param(
+ "dag_maker",
+ {"folder_dag_a_etl_extract", "folder_dag_a_etl_load"},
+ id="dag_maker-bundle",
+ ),
+ pytest.param(OTHER_BUNDLE, {"folder_dag_other_etl"},
id="other-bundle"),
+ ],
+ )
+ def test_folder_filter_scoped_by_bundle(self, test_client, bundle_name,
expected_dag_ids):
+ # Selecting a folder in the UI combines the folder path with its
bundle, so the
+ # same ``team_a/etl`` path resolves to different Dags in different
bundles.
+ response = test_client.get(
+ "/dags",
+ params={"relative_fileloc_prefix": "team_a/etl", "bundle_name":
bundle_name},
+ )
+ assert response.status_code == 200
+ returned = {dag["dag_id"] for dag in response.json()["dags"]}
+ assert returned & set(FOLDER_DAGS) == expected_dag_ids
+
+ 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