This is an automated email from the ASF dual-hosted git repository.
bbovenzi 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 e51289d6892 Add import error to deactivated dag (#65687)
e51289d6892 is described below
commit e51289d689236b125112ed1b195854714ab8418a
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Jun 10 14:15:28 2026 -0400
Add import error to deactivated dag (#65687)
* # This is a combination of 2 commits.
# This is the 1st commit message:
Add import error to dag page when deactivated
# This is the commit message #2:
Move to modal
* Add import error to dag page when deactivated
* Change badge to a banner
* Fix test
* Cleanup banner code
* Add bundleName param
* Fix CI
---
.../src/airflow/api_fastapi/common/parameters.py | 22 +++
.../core_api/openapi/v2-rest-api-generated.yaml | 24 ++++
.../core_api/routes/public/import_error.py | 7 +-
.../src/airflow/ui/openapi-gen/queries/common.ts | 6 +-
.../ui/openapi-gen/queries/ensureQueryData.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/prefetch.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/queries.ts | 8 +-
.../src/airflow/ui/openapi-gen/queries/suspense.ts | 8 +-
.../ui/openapi-gen/requests/services.gen.ts | 6 +-
.../airflow/ui/openapi-gen/requests/types.gen.ts | 8 ++
.../ui/src/components/DagDeactivatedBadge.tsx | 26 ----
.../src/components/DagDeactivatedBanner.test.tsx | 147 +++++++++++++++++++++
.../ui/src/components/DagDeactivatedBanner.tsx | 70 ++++++++++
.../src/airflow/ui/src/components/HeaderCard.tsx | 47 ++++---
.../ui/src/layouts/Details/DagBreadcrumb.tsx | 5 +-
.../ui/src/layouts/Details/DetailsLayout.tsx | 4 +-
.../ui/src/pages/Dag/DagImportErrorModal.tsx | 72 ++++++++++
.../src/airflow/ui/src/pages/Dag/Header.test.tsx | 1 -
.../src/airflow/ui/src/pages/Dag/Header.tsx | 11 +-
.../src/airflow/ui/src/pages/DagsList/DagsList.tsx | 4 +-
.../{DAGImportErrors.tsx => DagImportErrors.tsx} | 7 +-
...ortErrorsModal.tsx => DagImportErrorsModal.tsx} | 38 +++---
.../airflow/ui/src/pages/Dashboard/Stats/Stats.tsx | 4 +-
.../core_api/routes/public/test_import_error.py | 105 +++++++++++++++
24 files changed, 553 insertions(+), 93 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/common/parameters.py
b/airflow-core/src/airflow/api_fastapi/common/parameters.py
index 685736ed2f4..d18f8dc7ff5 100644
--- a/airflow-core/src/airflow/api_fastapi/common/parameters.py
+++ b/airflow-core/src/airflow/api_fastapi/common/parameters.py
@@ -1756,3 +1756,25 @@ QueryParseImportErrorFilenamePrefixPatternSearch =
Annotated[
_PrefixSearchParam,
Depends(prefix_search_param_factory(ParseImportError.filename,
"filename_prefix_pattern")),
]
+QueryParseImportErrorFilenameFilter = Annotated[
+ FilterParam,
+ Depends(
+ filter_param_factory(
+ ParseImportError.filename,
+ str | None,
+ filter_name="filename",
+ description="Exact filename match. Returns only the import error
for this specific file path.",
+ )
+ ),
+]
+QueryParseImportErrorBundleNameFilter = Annotated[
+ FilterParam,
+ Depends(
+ filter_param_factory(
+ ParseImportError.bundle_name,
+ str | None,
+ filter_name="bundle_name",
+ description="Exact bundle name match. Returns only import errors
from this specific bundle.",
+ )
+ ),
+]
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
index 87fc2a858b9..96f5bdec1ff 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
+++
b/airflow-core/src/airflow/api_fastapi/core_api/openapi/v2-rest-api-generated.yaml
@@ -5021,6 +5021,30 @@ paths:
\ stays index-compatible under locale-aware collations \u2014 e.g.
`test_`\
\ effectively matches items starting with `test`, and `s3://`
matches items\
\ starting with `s3`."
+ - name: filename
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Exact filename match. Returns only the import error for
this
+ specific file path.
+ title: Filename
+ description: Exact filename match. Returns only the import error for
this
+ specific file path.
+ - name: bundle_name
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Exact bundle name match. Returns only import errors
from this
+ specific bundle.
+ title: Bundle Name
+ description: Exact bundle name match. Returns only import errors from
this
+ specific bundle.
responses:
'200':
description: Successful Response
diff --git
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/import_error.py
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/import_error.py
index 2fcc94436b2..63d97a9b7b9 100644
---
a/airflow-core/src/airflow/api_fastapi/core_api/routes/public/import_error.py
+++
b/airflow-core/src/airflow/api_fastapi/core_api/routes/public/import_error.py
@@ -37,6 +37,8 @@ from airflow.api_fastapi.common.db.common import (
from airflow.api_fastapi.common.parameters import (
QueryLimit,
QueryOffset,
+ QueryParseImportErrorBundleNameFilter,
+ QueryParseImportErrorFilenameFilter,
QueryParseImportErrorFilenamePatternSearch,
QueryParseImportErrorFilenamePrefixPatternSearch,
SortParam,
@@ -145,6 +147,8 @@ def get_import_errors(
],
filename_pattern: QueryParseImportErrorFilenamePatternSearch,
filename_prefix_pattern: QueryParseImportErrorFilenamePrefixPatternSearch,
+ filename: QueryParseImportErrorFilenameFilter,
+ bundle_name: QueryParseImportErrorBundleNameFilter,
session: SessionDep,
user: GetUserDep,
) -> ImportErrorCollectionResponse:
@@ -211,7 +215,7 @@ def get_import_errors(
filtered_import_errors_stmt = apply_filters_to_select(
statement=import_errors_stmt,
- filters=[filename_pattern, filename_prefix_pattern],
+ filters=[filename_pattern, filename_prefix_pattern, filename,
bundle_name],
)
import_error_ids_stmt = (
filtered_import_errors_stmt.with_only_columns(
@@ -230,6 +234,7 @@ def get_import_errors(
# import error objects, not to the joined Dag rows.
paginated_import_error_ids_select, _ = paginated_select(
statement=import_error_ids_stmt,
+ filters=[],
order_by=order_by,
offset=offset,
limit=limit,
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 0933a3fea4d..82c63b58a69 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/common.ts
@@ -671,13 +671,15 @@ export const UseImportErrorServiceGetImportErrorKeyFn =
({ importErrorId }: {
export type ImportErrorServiceGetImportErrorsDefaultResponse =
Awaited<ReturnType<typeof ImportErrorService.getImportErrors>>;
export type ImportErrorServiceGetImportErrorsQueryResult<TData =
ImportErrorServiceGetImportErrorsDefaultResponse, TError = unknown> =
UseQueryResult<TData, TError>;
export const useImportErrorServiceGetImportErrorsKey =
"ImportErrorServiceGetImportErrors";
-export const UseImportErrorServiceGetImportErrorsKeyFn = ({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }: {
+export const UseImportErrorServiceGetImportErrorsKeyFn = ({ bundleName,
filename, filenamePattern, filenamePrefixPattern, limit, offset, orderBy }: {
+ bundleName?: string;
+ filename?: string;
filenamePattern?: string;
filenamePrefixPattern?: string;
limit?: number;
offset?: number;
orderBy?: string[];
-} = {}, queryKey?: Array<unknown>) =>
[useImportErrorServiceGetImportErrorsKey, ...(queryKey ?? [{ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }])];
+} = {}, queryKey?: Array<unknown>) =>
[useImportErrorServiceGetImportErrorsKey, ...(queryKey ?? [{ bundleName,
filename, filenamePattern, filenamePrefixPattern, limit, offset, orderBy }])];
export type JobServiceGetJobsDefaultResponse = Awaited<ReturnType<typeof
JobService.getJobs>>;
export type JobServiceGetJobsQueryResult<TData =
JobServiceGetJobsDefaultResponse, TError = unknown> = UseQueryResult<TData,
TError>;
export const useJobServiceGetJobsKey = "JobServiceGetJobs";
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 30096ac546b..48f61be34d1 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/ensureQueryData.ts
@@ -1372,16 +1372,20 @@ export const
ensureUseImportErrorServiceGetImportErrorData = (queryClient: Query
*
* **Performance note:** this full-match pattern is evaluated as ``ILIKE
'%term%'`` and most of the time prevents the database from using B-tree
indexes, which can be very slow on large tables. Prefer the equivalent
``filename_prefix_pattern`` parameter when possible.
* @param data.filenamePrefixPattern Prefix match — returns items whose value
starts with the given string (case-sensitive, index-friendly). Use the pipe `|`
operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. Wildcard
characters (`%`, `_`) are treated as literal characters. Trailing
non-alphanumeric characters in the prefix are stripped before matching so the
range scan stays index-compatible under locale-aware collations — e.g. `test_`
effectively matches items starting with [...]
+* @param data.filename Exact filename match. Returns only the import error for
this specific file path.
+* @param data.bundleName Exact bundle name match. Returns only import errors
from this specific bundle.
* @returns ImportErrorCollectionResponse Successful Response
* @throws ApiError
*/
-export const ensureUseImportErrorServiceGetImportErrorsData = (queryClient:
QueryClient, { filenamePattern, filenamePrefixPattern, limit, offset, orderBy
}: {
+export const ensureUseImportErrorServiceGetImportErrorsData = (queryClient:
QueryClient, { bundleName, filename, filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }: {
+ bundleName?: string;
+ filename?: string;
filenamePattern?: string;
filenamePrefixPattern?: string;
limit?: number;
offset?: number;
orderBy?: string[];
-} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }), queryFn: () =>
ImportErrorService.getImportErrors({ filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }) });
+} = {}) => queryClient.ensureQueryData({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }), queryFn: ()
=> ImportErrorService.getImportErrors({ bundleName, filename, filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }) });
/**
* Get Jobs
* Get all jobs.
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 8f78029a31c..d4fb4ae8896 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/prefetch.ts
@@ -1372,16 +1372,20 @@ export const
prefetchUseImportErrorServiceGetImportError = (queryClient: QueryCl
*
* **Performance note:** this full-match pattern is evaluated as ``ILIKE
'%term%'`` and most of the time prevents the database from using B-tree
indexes, which can be very slow on large tables. Prefer the equivalent
``filename_prefix_pattern`` parameter when possible.
* @param data.filenamePrefixPattern Prefix match — returns items whose value
starts with the given string (case-sensitive, index-friendly). Use the pipe `|`
operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. Wildcard
characters (`%`, `_`) are treated as literal characters. Trailing
non-alphanumeric characters in the prefix are stripped before matching so the
range scan stays index-compatible under locale-aware collations — e.g. `test_`
effectively matches items starting with [...]
+* @param data.filename Exact filename match. Returns only the import error for
this specific file path.
+* @param data.bundleName Exact bundle name match. Returns only import errors
from this specific bundle.
* @returns ImportErrorCollectionResponse Successful Response
* @throws ApiError
*/
-export const prefetchUseImportErrorServiceGetImportErrors = (queryClient:
QueryClient, { filenamePattern, filenamePrefixPattern, limit, offset, orderBy
}: {
+export const prefetchUseImportErrorServiceGetImportErrors = (queryClient:
QueryClient, { bundleName, filename, filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }: {
+ bundleName?: string;
+ filename?: string;
filenamePattern?: string;
filenamePrefixPattern?: string;
limit?: number;
offset?: number;
orderBy?: string[];
-} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }), queryFn: () =>
ImportErrorService.getImportErrors({ filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }) });
+} = {}) => queryClient.prefetchQuery({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }), queryFn: ()
=> ImportErrorService.getImportErrors({ bundleName, filename, filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }) });
/**
* Get Jobs
* Get all jobs.
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 8f5c660029c..91662271aca 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/queries.ts
@@ -1372,16 +1372,20 @@ export const useImportErrorServiceGetImportError =
<TData = Common.ImportErrorSe
*
* **Performance note:** this full-match pattern is evaluated as ``ILIKE
'%term%'`` and most of the time prevents the database from using B-tree
indexes, which can be very slow on large tables. Prefer the equivalent
``filename_prefix_pattern`` parameter when possible.
* @param data.filenamePrefixPattern Prefix match — returns items whose value
starts with the given string (case-sensitive, index-friendly). Use the pipe `|`
operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. Wildcard
characters (`%`, `_`) are treated as literal characters. Trailing
non-alphanumeric characters in the prefix are stripped before matching so the
range scan stays index-compatible under locale-aware collations — e.g. `test_`
effectively matches items starting with [...]
+* @param data.filename Exact filename match. Returns only the import error for
this specific file path.
+* @param data.bundleName Exact bundle name match. Returns only import errors
from this specific bundle.
* @returns ImportErrorCollectionResponse Successful Response
* @throws ApiError
*/
-export const useImportErrorServiceGetImportErrors = <TData =
Common.ImportErrorServiceGetImportErrorsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }: {
+export const useImportErrorServiceGetImportErrors = <TData =
Common.ImportErrorServiceGetImportErrorsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }: {
+ bundleName?: string;
+ filename?: string;
filenamePattern?: string;
filenamePrefixPattern?: string;
limit?: number;
offset?: number;
orderBy?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }, queryKey), queryFn: () =>
ImportErrorService.getImportErrors({ filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }) as TData, ...options });
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useQuery<TData, TError>({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }, queryKey),
queryFn: () => ImportErrorService.getImportErrors({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }) as TData,
...options });
/**
* Get Jobs
* Get all jobs.
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 3df940743cc..b810526782c 100644
--- a/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
+++ b/airflow-core/src/airflow/ui/openapi-gen/queries/suspense.ts
@@ -1372,16 +1372,20 @@ export const
useImportErrorServiceGetImportErrorSuspense = <TData = Common.Impor
*
* **Performance note:** this full-match pattern is evaluated as ``ILIKE
'%term%'`` and most of the time prevents the database from using B-tree
indexes, which can be very slow on large tables. Prefer the equivalent
``filename_prefix_pattern`` parameter when possible.
* @param data.filenamePrefixPattern Prefix match — returns items whose value
starts with the given string (case-sensitive, index-friendly). Use the pipe `|`
operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all. Wildcard
characters (`%`, `_`) are treated as literal characters. Trailing
non-alphanumeric characters in the prefix are stripped before matching so the
range scan stays index-compatible under locale-aware collations — e.g. `test_`
effectively matches items starting with [...]
+* @param data.filename Exact filename match. Returns only the import error for
this specific file path.
+* @param data.bundleName Exact bundle name match. Returns only import errors
from this specific bundle.
* @returns ImportErrorCollectionResponse Successful Response
* @throws ApiError
*/
-export const useImportErrorServiceGetImportErrorsSuspense = <TData =
Common.ImportErrorServiceGetImportErrorsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }: {
+export const useImportErrorServiceGetImportErrorsSuspense = <TData =
Common.ImportErrorServiceGetImportErrorsDefaultResponse, TError = unknown,
TQueryKey extends Array<unknown> = unknown[]>({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }: {
+ bundleName?: string;
+ filename?: string;
filenamePattern?: string;
filenamePrefixPattern?: string;
limit?: number;
offset?: number;
orderBy?: string[];
-} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ filenamePattern,
filenamePrefixPattern, limit, offset, orderBy }, queryKey), queryFn: () =>
ImportErrorService.getImportErrors({ filenamePattern, filenamePrefixPattern,
limit, offset, orderBy }) as TData, ...options });
+} = {}, queryKey?: TQueryKey, options?: Omit<UseQueryOptions<TData, TError>,
"queryKey" | "queryFn">) => useSuspenseQuery<TData, TError>({ queryKey:
Common.UseImportErrorServiceGetImportErrorsKeyFn({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }, queryKey),
queryFn: () => ImportErrorService.getImportErrors({ bundleName, filename,
filenamePattern, filenamePrefixPattern, limit, offset, orderBy }) as TData,
...options });
/**
* Get Jobs
* Get all jobs.
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 d69a06aa60c..f8205d72084 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
@@ -3291,6 +3291,8 @@ export class ImportErrorService {
*
* **Performance note:** this full-match pattern is evaluated as ``ILIKE
'%term%'`` and most of the time prevents the database from using B-tree
indexes, which can be very slow on large tables. Prefer the equivalent
``filename_prefix_pattern`` parameter when possible.
* @param data.filenamePrefixPattern Prefix match — returns items whose
value starts with the given string (case-sensitive, index-friendly). Use the
pipe `|` operator for OR logic (e.g. `dag1|dag2`). Use `~` to match all.
Wildcard characters (`%`, `_`) are treated as literal characters. Trailing
non-alphanumeric characters in the prefix are stripped before matching so the
range scan stays index-compatible under locale-aware collations — e.g. `test_`
effectively matches items starting [...]
+ * @param data.filename Exact filename match. Returns only the import
error for this specific file path.
+ * @param data.bundleName Exact bundle name match. Returns only import
errors from this specific bundle.
* @returns ImportErrorCollectionResponse Successful Response
* @throws ApiError
*/
@@ -3303,7 +3305,9 @@ export class ImportErrorService {
offset: data.offset,
order_by: data.orderBy,
filename_pattern: data.filenamePattern,
- filename_prefix_pattern: data.filenamePrefixPattern
+ filename_prefix_pattern: data.filenamePrefixPattern,
+ filename: data.filename,
+ bundle_name: data.bundleName
},
errors: {
401: 'Unauthorized',
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 62260fa6113..e8317de2300 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
@@ -3948,6 +3948,14 @@ export type GetImportErrorData = {
export type GetImportErrorResponse = ImportErrorResponse;
export type GetImportErrorsData = {
+ /**
+ * Exact bundle name match. Returns only import errors from this specific
bundle.
+ */
+ bundleName?: string | null;
+ /**
+ * Exact filename match. Returns only the import error for this specific
file path.
+ */
+ filename?: string | null;
/**
* SQL LIKE expression — use `%` / `_` wildcards (e.g. `%customer_%`). or
the pipe `|` operator for OR logic (e.g. `dag1 | dag2`). Regular expressions
are **not** supported.
*
diff --git a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx
deleted file mode 100644
index ad915b256be..00000000000
--- a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBadge.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-/*!
- * 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 { Badge } from "@chakra-ui/react";
-import { useTranslation } from "react-i18next";
-
-export const DagDeactivatedBadge = () => {
- const { t: translate } = useTranslation("dag");
-
- return <Badge
colorPalette="orange">{translate("header.status.deactivated")}</Badge>;
-};
diff --git
a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx
new file mode 100644
index 00000000000..91530a0d476
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.test.tsx
@@ -0,0 +1,147 @@
+/*!
+ * 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, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import i18n from "src/i18n/config";
+import { Wrapper } from "src/utils/Wrapper";
+
+import dagLocale from "../../public/i18n/locales/en/dag.json";
+import dashboardLocale from "../../public/i18n/locales/en/dashboard.json";
+import { DagDeactivatedBanner } from "./DagDeactivatedBanner";
+
+const { mockUseDagServiceGetDag, mockUseImportErrorServiceGetImportErrors } =
vi.hoisted(() => ({
+ mockUseDagServiceGetDag: vi.fn(),
+ mockUseImportErrorServiceGetImportErrors: vi.fn(),
+}));
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+
+ return {
+ ...actual,
+ useParams: () => ({ dagId: "stale_dag" }),
+ };
+});
+
+vi.mock("openapi/queries", async (importOriginal) => {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports --
`import()` type is the standard pattern for typing `importOriginal` in Vitest
mocks.
+ const actual = await importOriginal<typeof import("openapi/queries")>();
+
+ return {
+ ...actual,
+ useDagServiceGetDag: mockUseDagServiceGetDag,
+ useImportErrorServiceGetImportErrors:
mockUseImportErrorServiceGetImportErrors,
+ };
+});
+
+const staleDagQuery = {
+ data: { is_stale: true, relative_fileloc: "stale_dag.py" },
+ isLoading: false,
+};
+
+const notStaleDagQuery = {
+ data: { is_stale: false, relative_fileloc: "stale_dag.py" },
+ isLoading: false,
+};
+
+const emptyImportErrorsQuery = {
+ data: { import_errors: [], total_entries: 0 },
+ error: null,
+ isError: false,
+ isLoading: false,
+ isPending: false,
+};
+
+describe("DagDeactivatedBanner", () => {
+ beforeEach(() => {
+ i18n.addResourceBundle("en", "dag", dagLocale, true, true);
+ i18n.addResourceBundle("en", "dashboard", dashboardLocale, true, true);
+ mockUseDagServiceGetDag.mockReturnValue(staleDagQuery);
+
mockUseImportErrorServiceGetImportErrors.mockReturnValue(emptyImportErrorsQuery);
+ });
+
+ it("does not render when the dag is not stale", () => {
+ mockUseDagServiceGetDag.mockReturnValue(notStaleDagQuery);
+
+ render(
+ <Wrapper>
+ <DagDeactivatedBanner />
+ </Wrapper>,
+ );
+
+ expect(screen.queryByText(i18n.t("header.status.deactivated", { ns: "dag"
}))).not.toBeInTheDocument();
+ });
+
+ it("shows a deactivated banner when stale with no import error", () => {
+ render(
+ <Wrapper>
+ <DagDeactivatedBanner />
+ </Wrapper>,
+ );
+
+ expect(screen.getByText(i18n.t("header.status.deactivated", { ns: "dag"
}))).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", {
+ name: i18n.t("importErrors.dagImportError", { count: 1, ns:
"dashboard" }),
+ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows a deactivated banner with an import error button when the API
returns a file-scoped error", async () => {
+ mockUseImportErrorServiceGetImportErrors.mockReturnValue({
+ ...emptyImportErrorsQuery,
+ data: {
+ import_errors: [
+ {
+ bundle_name: "dags-folder",
+ filename: "stale_dag.py",
+ import_error_id: 42,
+ stack_trace: "Traceback (most recent call last):\nSyntaxError:
invalid syntax",
+ timestamp: "2025-02-01T12:00:00Z",
+ },
+ ],
+ total_entries: 1,
+ },
+ });
+
+ render(
+ <Wrapper>
+ <DagDeactivatedBanner />
+ </Wrapper>,
+ );
+
+ expect(screen.getByText(i18n.t("header.status.deactivated", { ns: "dag"
}))).toBeInTheDocument();
+
+ const importErrorButton = screen.getByRole("button", {
+ name: i18n.t("importErrors.dagImportError", { count: 1, ns: "dashboard"
}),
+ });
+
+ expect(importErrorButton).toBeInTheDocument();
+ expect(screen.queryByText(/SyntaxError: invalid
syntax/u)).not.toBeInTheDocument();
+
+ fireEvent.click(importErrorButton);
+
+ await waitFor(() => {
+ expect(screen.getByText("stale_dag.py")).toBeInTheDocument();
+ });
+ expect(screen.getByText(/SyntaxError: invalid
syntax/u)).toBeInTheDocument();
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx
b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx
new file mode 100644
index 00000000000..5ac812a78e1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/DagDeactivatedBanner.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 { Button, HStack, Text, useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { LuFileWarning } from "react-icons/lu";
+import { useParams } from "react-router-dom";
+
+import { useDagServiceGetDag, useImportErrorServiceGetImportErrors } from
"openapi/queries";
+import { DagImportErrorModal } from "src/pages/Dag/DagImportErrorModal";
+
+export const DagDeactivatedBanner = () => {
+ const { t: translate } = useTranslation(["dag", "dashboard"]);
+ const { dagId = "" } = useParams();
+ const { onClose, onOpen, open } = useDisclosure();
+
+ const { data: dag } = useDagServiceGetDag({ dagId }, undefined, { enabled:
dagId !== "" });
+ const relativeFileloc = dag?.relative_fileloc ?? "";
+ const bundleName = dag?.bundle_name ?? undefined;
+
+ const { data } = useImportErrorServiceGetImportErrors(
+ { bundleName, filename: relativeFileloc },
+ undefined,
+ { enabled: dag?.is_stale && relativeFileloc.length > 0 },
+ );
+
+ if (dagId === "" || !dag?.is_stale) {
+ return undefined;
+ }
+
+ const importError = data?.import_errors[0];
+
+ return (
+ <HStack bg="bg.warning" color="fg.warning" justifyContent="space-between"
px={3} py={1}>
+ <Text>{translate("header.status.deactivated")}</Text>
+ {importError ? (
+ <>
+ <Button
+ borderColor="fg.warning"
+ colorPalette="warning"
+ onClick={onOpen}
+ size="xs"
+ variant="outline"
+ >
+ <HStack gap={1}>
+ <LuFileWarning size={14} />
+ {translate("dashboard:importErrors.dagImportError", { count: 1
})}
+ </HStack>
+ </Button>
+ <DagImportErrorModal importError={importError} onClose={onClose}
open={open} />
+ </>
+ ) : undefined}
+ </HStack>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
index bc08d42b59d..62b2c86c605 100644
--- a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
+++ b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
@@ -24,6 +24,8 @@ import type { TaskInstanceState } from
"openapi/requests/types.gen";
import { Stat } from "src/components/Stat";
import { StateBadge } from "src/components/StateBadge";
+import { DagDeactivatedBanner } from "./DagDeactivatedBanner";
+
type Props = {
readonly actions?: ReactNode;
readonly icon: ReactNode;
@@ -37,26 +39,35 @@ export const HeaderCard = ({ actions, icon, state, stats,
subTitle, title }: Pro
const { t: translate } = useTranslation();
return (
- <Box borderColor="border.emphasized" borderRadius={8} borderWidth={1}
data-testid="header-card" p={2}>
- <Flex alignItems="center" flexWrap="wrap" justifyContent="space-between"
mb={2}>
- <Flex alignItems="center" flexWrap="wrap" gap={2}>
- <Heading size="xl">{icon}</Heading>
- <Heading size="lg">{title}</Heading>
- <Heading size="lg">{subTitle}</Heading>
- {state === undefined ? undefined : (
- <StateBadge state={state}>{state ?
translate(`common:states.${state}`) : undefined}</StateBadge>
- )}
+ <Box
+ borderColor="border.emphasized"
+ borderRadius={8}
+ borderWidth={1}
+ data-testid="header-card"
+ overflow="hidden"
+ >
+ <DagDeactivatedBanner />
+ <Box p={2}>
+ <Flex alignItems="center" flexWrap="wrap"
justifyContent="space-between" mb={2}>
+ <Flex alignItems="center" flexWrap="wrap" gap={2}>
+ <Heading size="xl">{icon}</Heading>
+ <Heading size="lg">{title}</Heading>
+ <Heading size="lg">{subTitle}</Heading>
+ {state === undefined ? undefined : (
+ <StateBadge state={state}>{state ?
translate(`common:states.${state}`) : undefined}</StateBadge>
+ )}
+ </Flex>
+ <HStack gap={1}>{actions}</HStack>
</Flex>
- <HStack gap={1}>{actions}</HStack>
- </Flex>
- <HStack alignItems="flex-start" flexWrap="wrap" gap={5}
justifyContent="space-between" my={2}>
- {stats.map(({ label, value }) => (
- <GridItem key={label}>
- <Stat label={label}>{value}</Stat>
- </GridItem>
- ))}
- </HStack>
+ <HStack alignItems="flex-start" flexWrap="wrap" gap={5}
justifyContent="space-between" my={2}>
+ {stats.map(({ label, value }) => (
+ <GridItem key={label}>
+ <Stat label={label}>{value}</Stat>
+ </GridItem>
+ ))}
+ </HStack>
+ </Box>
</Box>
);
};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
index 822e8d7e663..8b2d46fa3a6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -27,7 +27,6 @@ import {
useTaskServiceGetTask,
} from "openapi/queries";
import { BreadcrumbStats } from "src/components/BreadcrumbStats";
-import { DagDeactivatedBadge } from "src/components/DagDeactivatedBadge";
import { StateBadge } from "src/components/StateBadge";
import { TogglePause } from "src/components/TogglePause";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -66,9 +65,7 @@ export const DagBreadcrumb = () => {
[
{
label: dag?.dag_display_name ?? dagId,
- labelExtra: dag?.is_stale ? (
- <DagDeactivatedBadge />
- ) : (
+ labelExtra: dag?.is_stale ? undefined : (
<TogglePause
dagDisplayName={dag?.dag_display_name}
dagId={dagId}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index 5cfd5832c67..034d51e0c37 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -218,7 +218,9 @@ export const DetailsLayout = ({ children, error, isLoading,
tabs }: Props) => {
<GroupsProvider dagId={dagId}>
<Box display="flex" flex={1} flexDirection="column" minH={0} minW={{
base: "1280px", md: "auto" }}>
<HStack justifyContent="space-between" mb={2}>
- <DagBreadcrumb />
+ <Flex alignItems="center" gap={1}>
+ <DagBreadcrumb />
+ </Flex>
<Flex gap={1}>
<SearchDagsButton />
{dag === undefined ? undefined : (
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DagImportErrorModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/DagImportErrorModal.tsx
new file mode 100644
index 00000000000..2d5a6a47595
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/DagImportErrorModal.tsx
@@ -0,0 +1,72 @@
+/*!
+ * 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 { ClipboardRoot, Heading, HStack, Text } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { LuFileWarning } from "react-icons/lu";
+import { PiFilePy } from "react-icons/pi";
+
+import type { ImportErrorResponse } from "openapi/requests/types.gen";
+import Time from "src/components/Time";
+import { ClipboardIconButton, Dialog } from "src/components/ui";
+
+type Props = {
+ readonly importError: ImportErrorResponse;
+ readonly onClose: () => void;
+ readonly open: boolean;
+};
+
+export const DagImportErrorModal = ({ importError, onClose, open }: Props) => {
+ const { t: translate } = useTranslation(["dashboard", "components"]);
+
+ return (
+ <Dialog.Root lazyMount onOpenChange={onClose} open={open}
scrollBehavior="inside" size="lg">
+ <Dialog.Content backdrop p={4}>
+ <Dialog.Header>
+ <HStack fontSize="xl" gap={2}>
+ <LuFileWarning />
+ <Heading>{translate("importErrors.dagImportError", { count: 1
})}</Heading>
+ </HStack>
+ </Dialog.Header>
+ <Dialog.CloseTrigger />
+ <Dialog.Body>
+ <HStack alignItems="center" flexWrap="wrap" gap={2} mb={2}>
+ <Text fontWeight="bold">
+ {translate("components:versionDetails.bundleName")}
+ {": "}
+ {importError.bundle_name}
+ </Text>
+ <PiFilePy />
+ {importError.filename}
+ <ClipboardRoot value={importError.filename}>
+ <ClipboardIconButton variant="outline" />
+ </ClipboardRoot>
+ </HStack>
+ <Text color="fg.muted" fontSize="sm" mb={2}>
+ {translate("importErrors.timestamp")}
+ {": "}
+ <Time datetime={importError.timestamp} />
+ </Text>
+ <Text color="fg.error" fontSize="sm" whiteSpace="pre-wrap">
+ <code>{importError.stack_trace}</code>
+ </Text>
+ </Dialog.Body>
+ </Dialog.Content>
+ </Dialog.Root>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
index d17683892eb..4be93388cca 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.test.tsx
@@ -57,7 +57,6 @@ describe("Header", () => {
</Wrapper>,
);
-
expect(screen.getByText(i18n.t("dag:header.status.deactivated"))).toBeInTheDocument();
expect(screen.queryByText(i18n.t("dag:dagDetails.nextRun"))).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reparse Dag"
})).not.toBeInTheDocument();
});
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
index c18671b3c6b..9e3d915b079 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Header.tsx
@@ -25,7 +25,6 @@ import { DagIcon } from "src/assets/DagIcon";
import { DeleteDagButton } from "src/components/DagActions/DeleteDagButton";
import { FavoriteDagButton } from
"src/components/DagActions/FavoriteDagButton";
import { ParseDagButton } from "src/components/DagActions/ParseDagButton";
-import { DagDeactivatedBadge } from "src/components/DagDeactivatedBadge";
import DagRunInfo from "src/components/DagRunInfo";
import { DagVersion } from "src/components/DagVersion";
import DisplayMarkdownButton from "src/components/DisplayMarkdownButton";
@@ -149,13 +148,9 @@ export const Header = ({
icon={<DagIcon />}
stats={stats}
subTitle={
- isStale ? (
- <DagDeactivatedBadge />
- ) : (
- dag !== undefined && (
- <TogglePause dagDisplayName={dag.dag_display_name}
dagId={dag.dag_id} isPaused={dag.is_paused} />
- )
- )
+ dag !== undefined && !isStale ? (
+ <TogglePause dagDisplayName={dag.dag_display_name}
dagId={dag.dag_id} isPaused={dag.is_paused} />
+ ) : undefined
}
title={dag?.dag_display_name ?? dagId}
/>
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 29af6d24f28..0e26e363c66 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -42,7 +42,7 @@ import { DagsLayout } from "src/layouts/DagsLayout";
import { useConfig } from "src/queries/useConfig";
import { useDags } from "src/queries/useDags";
-import { DAGImportErrors } from "../Dashboard/Stats/DAGImportErrors";
+import { DagImportErrors } from "../Dashboard/Stats/DagImportErrors";
import { DagCard } from "./DagCard";
import { DagTags } from "./DagTags";
import { DagsFilters } from "./DagsFilters";
@@ -297,7 +297,7 @@ export const DagsList = () => {
<Heading py={3} size="md">
{`${totalEntries} ${translate("dag", { count: totalEntries })}`}
</Heading>
- <DAGImportErrors iconOnly />
+ <DagImportErrors iconOnly />
</HStack>
{display === "card" ? (
<SortSelect handleSortChange={handleSortChange} orderBy={orderBy}
/>
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrors.tsx
similarity index 89%
rename from
airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx
rename to
airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrors.tsx
index 9819987aa63..df2bf5755ae 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrors.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrors.tsx
@@ -25,9 +25,9 @@ import { ErrorAlert } from "src/components/ErrorAlert";
import { StateBadge } from "src/components/StateBadge";
import { StatsCard } from "src/components/StatsCard";
-import { DAGImportErrorsModal } from "./DAGImportErrorsModal";
+import { DagImportErrorsModal } from "./DagImportErrorsModal";
-export const DAGImportErrors = ({ iconOnly = false }: { readonly iconOnly?:
boolean }) => {
+export const DagImportErrors = ({ iconOnly = false }: { readonly iconOnly?:
boolean }) => {
const { onClose, onOpen, open } = useDisclosure();
const { i18n, t: translate } = useTranslation("dashboard");
@@ -49,6 +49,7 @@ export const DAGImportErrors = ({ iconOnly = false }: {
readonly iconOnly?: bool
<ErrorAlert error={error} />
{iconOnly ? (
<StateBadge
+ aria-label={translate("importErrors.dagImportError", { count:
importErrorsCount })}
as={Button}
colorPalette="failed"
height={7}
@@ -69,7 +70,7 @@ export const DAGImportErrors = ({ iconOnly = false }: {
readonly iconOnly?: bool
onClick={onOpen}
/>
)}
- <DAGImportErrorsModal onClose={onClose} open={open} />
+ <DagImportErrorsModal onClose={onClose} open={open} />
</Box>
);
};
diff --git
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrorsModal.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrorsModal.tsx
similarity index 76%
rename from
airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrorsModal.tsx
rename to
airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrorsModal.tsx
index 9a31250dcac..61c0bd57bc4 100644
---
a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DAGImportErrorsModal.tsx
+++
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/DagImportErrorsModal.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Heading, Text, HStack, ClipboardRoot } from "@chakra-ui/react";
+import { Box, ClipboardRoot, Heading, HStack, Text } from "@chakra-ui/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LuFileWarning } from "react-icons/lu";
@@ -35,7 +35,7 @@ type ImportDAGErrorModalProps = {
const PAGE_LIMIT = 15;
-export const DAGImportErrorsModal: React.FC<ImportDAGErrorModalProps> = ({
onClose, open }) => {
+export const DagImportErrorsModal: React.FC<ImportDAGErrorModalProps> = ({
onClose, open }) => {
const [page, setPage] = useState(1);
const [searchQuery, setSearchQuery] = useState("");
@@ -81,20 +81,26 @@ export const DAGImportErrorsModal:
React.FC<ImportDAGErrorModalProps> = ({ onClo
<Dialog.Body>
<Accordion.Root collapsible multiple size="md" variant="enclosed">
- {data?.import_errors.map((importError) => (
- <Accordion.Item key={importError.import_error_id}
value={importError.filename}>
- <Accordion.ItemTrigger cursor="pointer">
- <Text display="flex" fontWeight="bold">
- {translate("components:versionDetails.bundleName")}
- {": "}
- {importError.bundle_name}
- </Text>
- <PiFilePy />
- {importError.filename}
- <ClipboardRoot onClick={(event) => event.stopPropagation()}
value={importError.filename}>
- <ClipboardIconButton variant="outline" />
- </ClipboardRoot>
- </Accordion.ItemTrigger>
+ {(data?.import_errors ?? []).map((importError) => (
+ <Accordion.Item key={importError.import_error_id}
value={String(importError.import_error_id)}>
+ <HStack align="stretch" gap={0} w="100%">
+ <Accordion.ItemTrigger cursor="pointer" flex="1">
+ <HStack alignItems="center" flexWrap="wrap" gap={2}
w="100%">
+ <Text display="flex" fontWeight="bold">
+ {translate("components:versionDetails.bundleName")}
+ {": "}
+ {importError.bundle_name}
+ </Text>
+ <PiFilePy />
+ {importError.filename}
+ </HStack>
+ </Accordion.ItemTrigger>
+ <Box alignItems="center" display="flex" flexShrink={0}
pr={2}>
+ <ClipboardRoot value={importError.filename}>
+ <ClipboardIconButton variant="outline" />
+ </ClipboardRoot>
+ </Box>
+ </HStack>
<Accordion.ItemContent>
<Text color="fg.muted" fontSize="sm" mb={1}>
{translate("importErrors.timestamp")}
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
index 4192ad7b886..6fb64d2659d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Stats/Stats.tsx
@@ -25,7 +25,7 @@ import { NeedsReviewButton } from
"src/components/NeedsReviewButton";
import { StatsCard } from "src/components/StatsCard";
import { useAutoRefresh } from "src/utils";
-import { DAGImportErrors } from "./DAGImportErrors";
+import { DagImportErrors } from "./DagImportErrors";
import { PluginImportErrors } from "./PluginImportErrors";
export const Stats = () => {
@@ -64,7 +64,7 @@ export const Stats = () => {
state="failed"
/>
- <DAGImportErrors />
+ <DagImportErrors />
<PluginImportErrors />
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_import_error.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_import_error.py
index a595d139f92..0db0c07c5f7 100644
---
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_import_error.py
+++
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_import_error.py
@@ -387,6 +387,111 @@ class TestGetImportErrors:
import_error["filename"] for import_error in
response_json["import_errors"]
] == expected_filenames
+ @pytest.mark.parametrize(
+ ("filename", "expected_total_entries", "expected_filenames"),
+ [
+ (FILENAME1, 1, [FILENAME1]),
+ (FILENAME2, 1, [FILENAME2]),
+ ("nonexistent.py", 0, []),
+ ],
+ )
+
@mock.patch("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager")
+ def test_get_import_errors_exact_filename_filter(
+ self,
+ mock_get_auth_manager,
+ test_client,
+ filename,
+ expected_total_entries,
+ expected_filenames,
+ permitted_dag_model_all,
+ ):
+ """Test that the exact ``filename`` filter returns only the matching
file."""
+ set_mock_auth_manager__get_authorized_dag_ids(mock_get_auth_manager,
permitted_dag_model_all)
+ set_mock_auth_manager__batch_is_authorized_dag(mock_get_auth_manager,
True)
+
+ response = test_client.get("/importErrors", params={"filename":
filename})
+
+ assert response.status_code == 200
+ response_json = response.json()
+ assert response_json["total_entries"] == expected_total_entries
+ assert [ie["filename"] for ie in response_json["import_errors"]] ==
expected_filenames
+
+ @pytest.mark.parametrize(
+ ("bundle_name", "expected_total_entries", "expected_filenames"),
+ [
+ (BUNDLE_NAME, 3, [FILENAME1, FILENAME2, FILENAME3]),
+ ("nonexistent_bundle", 0, []),
+ ],
+ )
+
@mock.patch("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager")
+ def test_get_import_errors_bundle_name_filter(
+ self,
+ mock_get_auth_manager,
+ test_client,
+ bundle_name,
+ expected_total_entries,
+ expected_filenames,
+ permitted_dag_model_all,
+ ):
+ """Test that the ``bundle_name`` filter returns only errors from the
matching bundle."""
+ set_mock_auth_manager__get_authorized_dag_ids(mock_get_auth_manager,
permitted_dag_model_all)
+ set_mock_auth_manager__batch_is_authorized_dag(mock_get_auth_manager,
True)
+
+ response = test_client.get("/importErrors", params={"bundle_name":
bundle_name})
+
+ assert response.status_code == 200
+ response_json = response.json()
+ assert response_json["total_entries"] == expected_total_entries
+ assert [ie["filename"] for ie in response_json["import_errors"]] ==
expected_filenames
+
+
@mock.patch("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager")
+ def test_get_import_errors_bundle_name_and_filename_filter(
+ self,
+ mock_get_auth_manager,
+ test_client,
+ permitted_dag_model_all,
+ session,
+ ):
+ """Test that combining ``bundle_name`` and ``filename`` filters finds
the right error
+ when two bundles share the same relative_fileloc (the multi-bundle
cross-contamination case).
+ """
+ other_bundle = "other_bundle"
+ session.add(DagBundleModel(name=other_bundle))
+ session.flush()
+
+ shared_filename = FILENAME1
+ other_dag = DagModel(
+ fileloc=f"/other/{shared_filename}",
+ relative_fileloc=shared_filename,
+ dag_id="dag_in_other_bundle",
+ is_paused=False,
+ bundle_name=other_bundle,
+ )
+ session.add(other_dag)
+ session.add(
+ ParseImportError(
+ bundle_name=other_bundle,
+ filename=shared_filename,
+ stacktrace="wrong bundle error",
+ timestamp=TIMESTAMP1,
+ )
+ )
+ session.commit()
+
+ set_mock_auth_manager__get_authorized_dag_ids(
+ mock_get_auth_manager, permitted_dag_model_all | {other_dag.dag_id}
+ )
+ set_mock_auth_manager__batch_is_authorized_dag(mock_get_auth_manager,
True)
+
+ response = test_client.get(
+ "/importErrors", params={"filename": shared_filename,
"bundle_name": BUNDLE_NAME}
+ )
+ assert response.status_code == 200
+ response_json = response.json()
+ assert response_json["total_entries"] == 1
+ assert response_json["import_errors"][0]["bundle_name"] == BUNDLE_NAME
+ assert response_json["import_errors"][0]["stack_trace"] == STACKTRACE1
+
def test_should_raises_401_unauthenticated(self,
unauthenticated_test_client):
response = unauthenticated_test_client.get("/importErrors")
assert response.status_code == 401