diff --git a/airflow-core/src/airflow/ui/src/components/DagImportErrorAccordion.tsx b/airflow-core/src/airflow/ui/src/components/DagImportErrorAccordion.tsx new file mode 100644 index 0000000000000..3c92af63702b2 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/DagImportErrorAccordion.tsx @@ -0,0 +1,87 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Box, ClipboardRoot, 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 { StateBadge } from "src/components/StateBadge"; +import Time from "src/components/Time"; +import { Accordion, ClipboardIconButton } from "src/components/ui"; + +type Props = { + readonly defaultValue?: Array; + readonly importErrors: ReadonlyArray; + readonly showFileErrorIndicator?: boolean; +}; + +export const DagImportErrorAccordion = ({ + defaultValue, + importErrors, + showFileErrorIndicator = false, +}: Props) => { + const { t: translate } = useTranslation(["dashboard", "components"]); + + if (importErrors.length === 0) { + return undefined; + } + + return ( + + {importErrors.map((importError) => ( + + + + + {showFileErrorIndicator ? ( + + + + ) : undefined} + + {translate("components:versionDetails.bundleName")} + {": "} + {importError.bundle_name} + + + {importError.filename} + + + + + + + + + + + {translate("importErrors.timestamp")} + {": "} + + + {importError.stack_trace} + + + + ))} + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/components/DagImportErrorsIconBadge.tsx b/airflow-core/src/airflow/ui/src/components/DagImportErrorsIconBadge.tsx new file mode 100644 index 0000000000000..d39b8f4c40ee4 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/components/DagImportErrorsIconBadge.tsx @@ -0,0 +1,51 @@ +/*! + * 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 } from "@chakra-ui/react"; +import { LuFileWarning } from "react-icons/lu"; + +import { StateBadge } from "src/components/StateBadge"; + +export type DagImportErrorsIconBadgeProps = { + readonly "aria-label"?: string | undefined; + readonly count?: number; + readonly "data-testid"?: string | undefined; + readonly onClick: () => void; + readonly title: string; +}; + +export const DagImportErrorsIconBadge = ({ + "aria-label": ariaLabel, + count, + "data-testid": dataTestId, + onClick, + title, +}: DagImportErrorsIconBadgeProps) => ( + + + {count ?? undefined} + +); 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 82d1f4dc13a9c..4738386dd3a91 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -53,6 +53,7 @@ import { SearchParamsKeys } from "src/constants/searchParams"; import { VersionIndicatorOptions } from "src/constants/showVersionIndicatorOptions"; import { HoverProvider, useHover } from "src/context/hover"; import { OpenGroupsProvider } from "src/context/openGroups"; +import { DagImportError } from "src/pages/Dag/DagImportError"; import { useGridRuns } from "src/queries/useGridRuns.ts"; import { DagBreadcrumb } from "./DagBreadcrumb"; @@ -218,9 +219,12 @@ export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { return ( - - - + + + {dag === undefined ? undefined : } + + + {dag === undefined ? undefined : ( ({ + mockUseImportErrorServiceGetImportErrors: vi.fn(), +})); + +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(); + + return { + ...actual, + useImportErrorServiceGetImportErrors: mockUseImportErrorServiceGetImportErrors, + }; +}); + +const emptyImportErrorsQuery = { + data: { import_errors: [], total_entries: 0 }, + error: null, + isError: false, + isLoading: false, + isPending: false, +}; + +const staleDagFields = { + bundle_name: "dags-folder", + is_stale: true, + relative_fileloc: "stale_dag.py", +} as const; + +describe("DagImportError", () => { + beforeEach(() => { + i18n.addResourceBundle("en", "dashboard", dashboardLocale, true, true); + mockUseImportErrorServiceGetImportErrors.mockReturnValue(emptyImportErrorsQuery); + }); + + it("does not render when there is no matching import error", () => { + render( + + + , + ); + + expect(screen.queryByTestId("dag-import-error")).not.toBeInTheDocument(); + }); + + it("shows a matching import error 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( + + + , + ); + + expect(screen.getByTestId("dag-import-error")).toBeInTheDocument(); + const openButton = screen.getByRole("button", { + name: i18n.t("importErrors.dagImportError", { count: 1, ns: "dashboard" }), + }); + + expect(openButton).toBeInTheDocument(); + expect(screen.queryByText(/SyntaxError: invalid syntax/u)).not.toBeInTheDocument(); + + fireEvent.click(openButton); + + await waitFor(() => { + expect( + screen.getByText(i18n.t("importErrors.dagImportError", { count: 1, ns: "dashboard" })), + ).toBeInTheDocument(); + }); + expect(screen.getByText("stale_dag.py")).toBeInTheDocument(); + expect(screen.getByText(/SyntaxError: invalid syntax/u)).toBeInTheDocument(); + }); + + it("does not render when the dag is not stale", () => { + mockUseImportErrorServiceGetImportErrors.mockReturnValue({ + ...emptyImportErrorsQuery, + data: { + import_errors: [ + { + bundle_name: "dags-folder", + filename: "stale_dag.py", + import_error_id: 1, + stack_trace: "would match if stale", + timestamp: "2025-02-01T12:00:00Z", + }, + ], + total_entries: 1, + }, + }); + + render( + + + , + ); + + expect(screen.queryByTestId("dag-import-error")).not.toBeInTheDocument(); + }); + + it("does not render when bundle_name does not match", () => { + mockUseImportErrorServiceGetImportErrors.mockReturnValue({ + ...emptyImportErrorsQuery, + data: { + import_errors: [ + { + bundle_name: "other-bundle", + filename: "stale_dag.py", + import_error_id: 1, + stack_trace: "wrong bundle", + timestamp: "2025-02-01T12:00:00Z", + }, + ], + total_entries: 1, + }, + }); + + render( + + + , + ); + + expect(screen.queryByTestId("dag-import-error")).not.toBeInTheDocument(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/DagImportError.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/DagImportError.tsx new file mode 100644 index 0000000000000..13b34ec195408 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/DagImportError.tsx @@ -0,0 +1,75 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { Box, useDisclosure } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; + +import { useImportErrorServiceGetImportErrors } from "openapi/queries"; +import type { DAGResponse } from "openapi/requests/types.gen"; +import { DagImportErrorsIconBadge } from "src/components/DagImportErrorsIconBadge"; + +import { StaleDagImportErrorModal } from "./StaleDagImportErrorModal"; +import { selectLatestMatchingImportError } from "./selectLatestMatchingImportError"; + +const IMPORT_ERROR_FETCH_LIMIT = 100; + +type Props = { + readonly dag: Pick; +}; + +export const DagImportError = ({ dag }: Props) => { + const { t: translate } = useTranslation("dashboard"); + const { onClose, onOpen, open } = useDisclosure(); + const relativeFileloc = dag.relative_fileloc ?? ""; + const shouldFetch = dag.is_stale && relativeFileloc.length > 0; + + const { data } = useImportErrorServiceGetImportErrors( + { + filenamePattern: relativeFileloc, + limit: IMPORT_ERROR_FETCH_LIMIT, + offset: 0, + orderBy: ["-timestamp"], + }, + undefined, + { enabled: shouldFetch }, + ); + + if (!shouldFetch) { + return undefined; + } + + const matched = selectLatestMatchingImportError(data?.import_errors, relativeFileloc, dag.bundle_name); + + if (matched === undefined) { + return undefined; + } + + const importErrorLabel = translate("importErrors.dagImportError", { count: 1 }); + + return ( + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/StaleDagImportErrorModal.tsx b/airflow-core/src/airflow/ui/src/pages/Dag/StaleDagImportErrorModal.tsx new file mode 100644 index 0000000000000..6df8cf9320bf5 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/StaleDagImportErrorModal.tsx @@ -0,0 +1,53 @@ +/*! + * 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 { Heading, HStack } from "@chakra-ui/react"; +import { useTranslation } from "react-i18next"; +import { LuFileWarning } from "react-icons/lu"; + +import type { ImportErrorResponse } from "openapi/requests/types.gen"; +import { DagImportErrorAccordion } from "src/components/DagImportErrorAccordion"; +import { Dialog } from "src/components/ui"; + +type Props = { + readonly importError: ImportErrorResponse; + readonly onClose: () => void; + readonly open: boolean; +}; + +export const StaleDagImportErrorModal = ({ importError, onClose, open }: Props) => { + const { t: translate } = useTranslation("dashboard"); + const itemValue = String(importError.import_error_id); + + return ( + + + + + + {translate("importErrors.dagImportError", { count: 1 })} + + + + + + + + + ); +}; diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.test.ts b/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.test.ts new file mode 100644 index 0000000000000..6c5220298971a --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.test.ts @@ -0,0 +1,113 @@ +/*! + * 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 type { ImportErrorResponse } from "openapi/requests/types.gen"; + +import { selectLatestMatchingImportError } from "./selectLatestMatchingImportError"; + +const row = ( + overrides: Partial & Pick, +): ImportErrorResponse => ({ + bundle_name: "dags-folder", + filename: "path/to/dag.py", + stack_trace: "err", + ...overrides, +}); + +describe("selectLatestMatchingImportError", () => { + it("returns undefined when the list is empty", () => { + expect(selectLatestMatchingImportError(undefined, "path/to/dag.py", "dags-folder")).toBeUndefined(); + expect(selectLatestMatchingImportError([], "path/to/dag.py", "dags-folder")).toBeUndefined(); + }); + + it("matches exact filename and bundle", () => { + const errors = [ + row({ + bundle_name: "dags-folder", + filename: "other.py", + import_error_id: 1, + stack_trace: "wrong file", + timestamp: "2024-01-01T00:00:00Z", + }), + row({ + bundle_name: "dags-folder", + filename: "path/to/dag.py", + import_error_id: 2, + stack_trace: "match", + timestamp: "2024-01-02T00:00:00Z", + }), + ]; + + const picked = selectLatestMatchingImportError(errors, "path/to/dag.py", "dags-folder"); + + expect(picked?.stack_trace).toBe("match"); + }); + + it("treats null bundle the same on both sides", () => { + const errors = [ + row({ + bundle_name: null, + filename: "path/to/dag.py", + import_error_id: 1, + stack_trace: "ok", + timestamp: "2024-01-01T00:00:00Z", + }), + ]; + + expect(selectLatestMatchingImportError(errors, "path/to/dag.py", null)?.stack_trace).toBe("ok"); + }); + + it("returns the row with the latest timestamp when several match", () => { + const errors = [ + row({ + bundle_name: "dags-folder", + filename: "path/to/dag.py", + import_error_id: 1, + stack_trace: "older", + timestamp: "2024-01-01T00:00:00Z", + }), + row({ + bundle_name: "dags-folder", + filename: "path/to/dag.py", + import_error_id: 2, + stack_trace: "newer", + timestamp: "2024-06-01T00:00:00Z", + }), + ]; + + expect(selectLatestMatchingImportError(errors, "path/to/dag.py", "dags-folder")?.stack_trace).toBe( + "newer", + ); + }); + + it("returns undefined when bundle differs", () => { + const errors = [ + row({ + bundle_name: "other-bundle", + filename: "path/to/dag.py", + import_error_id: 1, + stack_trace: "wrong bundle", + timestamp: "2024-01-01T00:00:00Z", + }), + ]; + + expect(selectLatestMatchingImportError(errors, "path/to/dag.py", "dags-folder")).toBeUndefined(); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.ts b/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.ts new file mode 100644 index 0000000000000..ba6e2d4d61da1 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/pages/Dag/selectLatestMatchingImportError.ts @@ -0,0 +1,48 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ImportErrorResponse } from "openapi/requests/types.gen"; + +const sameBundle = (left: string | null | undefined, right: string | null | undefined): boolean => + (left ?? undefined) === (right ?? undefined); + +/** + * Pick the most recent import error row for this DAG's bundle + relative file path. + * The list API may return other files when `filename_pattern` is a substring match. + */ +export const selectLatestMatchingImportError = ( + importErrors: Array | undefined, + relativeFileloc: string, + bundleName: string | null | undefined, +): ImportErrorResponse | undefined => { + if (importErrors === undefined || importErrors.length === 0) { + return undefined; + } + + const matching = importErrors.filter( + (row) => row.filename === relativeFileloc && sameBundle(row.bundle_name, bundleName), + ); + + if (matching.length === 0) { + return undefined; + } + + return matching.reduce((latest, current) => + Date.parse(current.timestamp) > Date.parse(latest.timestamp) ? current : latest, + ); +}; 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 index 9819987aa634e..ca9e1c4b76af4 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 @@ -16,13 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { Box, Button, Skeleton, useDisclosure } from "@chakra-ui/react"; +import { Box, Skeleton, useDisclosure } from "@chakra-ui/react"; import { useTranslation } from "react-i18next"; import { LuFileWarning } from "react-icons/lu"; import { useImportErrorServiceGetImportErrors } from "openapi/queries/queries"; +import { DagImportErrorsIconBadge } from "src/components/DagImportErrorsIconBadge"; import { ErrorAlert } from "src/components/ErrorAlert"; -import { StateBadge } from "src/components/StateBadge"; import { StatsCard } from "src/components/StatsCard"; import { DAGImportErrorsModal } from "./DAGImportErrorsModal"; @@ -48,16 +48,11 @@ export const DAGImportErrors = ({ iconOnly = false }: { readonly iconOnly?: bool {iconOnly ? ( - - - {importErrorsCount} - + /> ) : ( = ({ onClo - - {data?.import_errors.map((importError) => ( - - - - {translate("components:versionDetails.bundleName")} - {": "} - {importError.bundle_name} - - - {importError.filename} - event.stopPropagation()} value={importError.filename}> - - - - - - {translate("importErrors.timestamp")} - {": "} - - - {importError.stack_trace} - - - - ))} - +