This is an automated email from the ASF dual-hosted git repository. rahulvats pushed a commit to branch py-client-sync in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 423837e7e20859888b449fb7979898892989de4a Author: Abhishek Mishra <[email protected]> AuthorDate: Tue Mar 24 22:34:58 2026 +0530 UI: Show clear permission toast for 403 errors on user actions (#61588) --- .../airflow/ui/public/i18n/locales/en/common.json | 10 ++- .../ui/src/components/ui/Toaster/createToaster.ts | 8 +- .../ui/src/components/ui/createErrorToaster.ts | 40 ---------- .../src/airflow/ui/src/queries/useClearRun.ts | 17 ++-- .../ui/src/queries/useClearTaskInstances.ts | 9 +++ .../src/airflow/ui/src/queries/useDagParsing.ts | 12 +-- .../airflow/ui/src/queries/useDeleteConnection.ts | 18 +++-- .../src/airflow/ui/src/queries/useDeleteDag.ts | 18 +++-- .../src/airflow/ui/src/queries/useDeleteDagRun.ts | 16 ++-- .../src/airflow/ui/src/queries/useDeletePool.ts | 18 +++-- .../ui/src/queries/useDeleteTaskInstance.ts | 16 ++-- .../airflow/ui/src/queries/useDeleteVariable.ts | 18 +++-- .../src/airflow/ui/src/queries/usePatchDagRun.ts | 19 ++--- .../airflow/ui/src/queries/usePatchTaskInstance.ts | 19 ++--- .../src/airflow/ui/src/queries/useTogglePause.ts | 10 +-- .../src/airflow/ui/src/queries/useTrigger.ts | 11 +-- .../airflow/ui/src/queries/useUpdateHITLDetail.ts | 9 +-- airflow-core/src/airflow/ui/src/queryClient.ts | 30 +++++++- .../src/airflow/ui/src/utils/errorHandling.ts | 90 ++++++++++++++++++++++ airflow-core/src/airflow/ui/src/utils/index.ts | 1 + 20 files changed, 248 insertions(+), 141 deletions(-) diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json index 4176a915062..0bbea8fc42d 100644 --- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json +++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json @@ -103,6 +103,12 @@ "notFound": "Page Not Found", "title": "Error" }, + "errors": { + "forbidden": { + "description": "You do not have permission to perform this action.", + "title": "Access Denied" + } + }, "expand": { "collapse": "Collapse", "expand": "Expand", @@ -314,10 +320,6 @@ "title": "Delete {{resourceName}} Request Submitted" } }, - "forbidden": { - "description": "You do not have permission to perform this action.", - "title": "Access Denied" - }, "import": { "error": "Import {{resourceName}} Request Failed", "success": { diff --git a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts index 9fa1c393c77..b7c78202bdf 100644 --- a/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts +++ b/airflow-core/src/airflow/ui/src/components/ui/Toaster/createToaster.ts @@ -18,7 +18,13 @@ */ import { createToaster } from "@chakra-ui/react"; -export const toaster = createToaster({ +const baseToaster = createToaster({ pauseOnPageIdle: true, placement: "bottom-end", }); + +// Extend toaster with isActive alias for consistency +export const toaster = { + ...baseToaster, + isActive: (id: string) => baseToaster.isVisible(id), +}; diff --git a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts b/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts deleted file mode 100644 index c59cabbf3b6..00000000000 --- a/airflow-core/src/airflow/ui/src/components/ui/createErrorToaster.ts +++ /dev/null @@ -1,40 +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 type { TFunction } from "i18next"; - -import type { ExpandedApiError } from "src/components/ErrorAlert"; -import { toaster } from "src/components/ui"; - -type ErrorToastMessage = { - readonly description: string; - readonly title: string; -}; - -export const createErrorToaster = - (translate: TFunction, fallbackMessage: ErrorToastMessage) => (error: unknown) => { - const isForbidden = (error as ExpandedApiError).status === 403; - - toaster.create({ - description: isForbidden - ? translate("toaster.forbidden.description", { ns: "common" }) - : fallbackMessage.description, - title: isForbidden ? translate("toaster.forbidden.title", { ns: "common" }) : fallbackMessage.title, - type: "error", - }); - }; diff --git a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts index af234cc39fc..1749955525e 100644 --- a/airflow-core/src/airflow/ui/src/queries/useClearRun.ts +++ b/airflow-core/src/airflow/ui/src/queries/useClearRun.ts @@ -28,7 +28,7 @@ import { useTaskInstanceServiceGetTaskInstancesKey, UseGridServiceGetGridRunsKeyFn, } from "openapi/queries"; -import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun"; @@ -44,12 +44,15 @@ export const useClearDagRun = ({ const queryClient = useQueryClient(); const { t: translate } = useTranslation("dags"); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("dags:runAndTaskActions.clear.error", { type: translate("dagRun_one") }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { type: translate("dagRun_one") }, + titleKey: "dags:runAndTaskActions.clear.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts index e8d13ad000e..b3dc26c9de7 100644 --- a/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts +++ b/airflow-core/src/airflow/ui/src/queries/useClearTaskInstances.ts @@ -50,6 +50,15 @@ export const useClearTaskInstances = ({ let detail: string; let description: string; + // Get status from error + const status = + (error as { status?: number }).status ?? (error as { response?: { status?: number } }).response?.status; + + // Skip 403 errors as they are handled by MutationCache + if (status === 403) { + return; + } + // Narrow the type safely if (typeof error === "object" && error !== null) { const apiError = error as ApiError; diff --git a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts index 2fc8c43c0e3..8557c271587 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDagParsing.ts @@ -25,15 +25,15 @@ import { UseDagSourceServiceGetDagSourceKeyFn, } from "openapi/queries"; import { toaster } from "src/components/ui"; -import { createErrorToaster } from "src/components/ui/createErrorToaster"; +import { createErrorToaster } from "src/utils"; export const useDagParsing = ({ dagId }: { readonly dagId: string }) => { const queryClient = useQueryClient(); - const { t: translate } = useTranslation(["dag", "common"]); - const onError = createErrorToaster(translate, { - description: translate("parse.toaster.error.description"), - title: translate("parse.toaster.error.title"), - }); + const { t: translate } = useTranslation("dag"); + + const onError = (error: unknown) => { + createErrorToaster(error, { titleKey: "dag:parse.toaster.error.title" }, translate); + }; const onSuccess = async () => { await queryClient.invalidateQueries({ diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts index 51e27589fc0..2b54384f8a3 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeleteConnection.ts @@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next"; import { useConnectionServiceDeleteConnection, useConnectionServiceGetConnectionsKey } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useDeleteConnection = ({ onSuccessConfirm }: { onSuccessConfirm: () => void }) => { const queryClient = useQueryClient(); const { t: translate } = useTranslation(["admin", "common"]); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("common:toaster.delete.error", { - resourceName: translate("admin:connections.connection_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("admin:connections.connection_one") }, + titleKey: "common:toaster.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts index bdd61379a2a..28518dd01ef 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDag.ts @@ -22,6 +22,7 @@ import { useTranslation } from "react-i18next"; import { useDagServiceDeleteDag, useDagServiceGetDagsUiKey } from "openapi/queries"; import { useDagServiceGetDagKey } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useDeleteDag = ({ dagId, @@ -33,14 +34,15 @@ export const useDeleteDag = ({ const queryClient = useQueryClient(); const { t: translate } = useTranslation(); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("toaster.delete.error", { - resourceName: translate("dag_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("dag_one") }, + titleKey: "toaster.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts index c5c75320551..91de2ba4f75 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeleteDagRun.ts @@ -27,6 +27,7 @@ import { useTaskInstanceServiceGetHitlDetailsKey, } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; type DeleteDagRunParams = { dagId: string; @@ -38,12 +39,15 @@ export const useDeleteDagRun = ({ dagId, dagRunId, onSuccessConfirm }: DeleteDag const { t: translate } = useTranslation(); const queryClient = useQueryClient(); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("dags:runAndTaskActions.delete.error", { type: translate("dagRun_one") }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { type: translate("dagRun_one") }, + titleKey: "dags:runAndTaskActions.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts index 87af3f9f7e2..7ce3857846a 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeletePool.ts @@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next"; import { usePoolServiceDeletePool, usePoolServiceGetPoolsKey } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useDeletePool = ({ onSuccessConfirm }: { onSuccessConfirm: () => void }) => { const queryClient = useQueryClient(); const { t: translate } = useTranslation(["common", "admin"]); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("toaster.delete.error", { - resourceName: translate("admin:pools.pool_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("admin:pools.pool_one") }, + titleKey: "toaster.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts index 0d7e8f266c3..cdf07e49056 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeleteTaskInstance.ts @@ -28,6 +28,7 @@ import { useTaskInstanceServiceGetHitlDetailsKey, } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; type DeleteTaskInstanceParams = { dagId: string; @@ -47,12 +48,15 @@ export const useDeleteTaskInstance = ({ const queryClient = useQueryClient(); const { t: translate } = useTranslation(["common", "dags"]); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("dags:runAndTaskActions.delete.error", { type: translate("taskInstance_one") }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { type: translate("taskInstance_one") }, + titleKey: "dags:runAndTaskActions.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts index a82544cb20f..9aee06d7f05 100644 --- a/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts +++ b/airflow-core/src/airflow/ui/src/queries/useDeleteVariable.ts @@ -21,19 +21,21 @@ import { useTranslation } from "react-i18next"; import { useVariableServiceDeleteVariable, useVariableServiceGetVariablesKey } from "openapi/queries"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useDeleteVariable = ({ onSuccessConfirm }: { onSuccessConfirm: () => void }) => { const queryClient = useQueryClient(); const { t: translate } = useTranslation(["common", "admin"]); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("toaster.delete.error", { - resourceName: translate("admin:variables.variable_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("admin:variables.variable_one") }, + titleKey: "toaster.delete.error", + }, + translate, + ); }; const onSuccess = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts index a49fcba8592..0c46147a7b4 100644 --- a/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts +++ b/airflow-core/src/airflow/ui/src/queries/usePatchDagRun.ts @@ -26,7 +26,7 @@ import { useTaskInstanceServiceGetTaskInstancesKey, UseGridServiceGetGridRunsKeyFn, } from "openapi/queries"; -import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; import { useClearDagRunDryRunKey } from "./useClearDagRunDryRun"; @@ -42,14 +42,15 @@ export const usePatchDagRun = ({ const queryClient = useQueryClient(); const { t: translate } = useTranslation(); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("toaster.update.error", { - resourceName: translate("dagRun_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("dagRun_one") }, + titleKey: "toaster.update.error", + }, + translate, + ); }; const onSuccessFn = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts index b94fadf8e13..402e8d63540 100644 --- a/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts +++ b/airflow-core/src/airflow/ui/src/queries/usePatchTaskInstance.ts @@ -26,7 +26,7 @@ import { useTaskInstanceServicePatchTaskInstance, UseGridServiceGetGridRunsKeyFn, } from "openapi/queries"; -import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; import { useClearTaskInstancesDryRunKey } from "./useClearTaskInstancesDryRun"; import { usePatchTaskInstanceDryRunKey } from "./usePatchTaskInstanceDryRun"; @@ -47,14 +47,15 @@ export const usePatchTaskInstance = ({ const queryClient = useQueryClient(); const { t: translate } = useTranslation(); - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("toaster.update.error", { - resourceName: translate("taskInstance_one"), - }), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster( + error, + { + params: { resourceName: translate("taskInstance_one") }, + titleKey: "toaster.update.error", + }, + translate, + ); }; const onSuccessFn = async () => { diff --git a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts index c19b5124586..04a7c510acb 100644 --- a/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts +++ b/airflow-core/src/airflow/ui/src/queries/useTogglePause.ts @@ -27,7 +27,7 @@ import { useDagServiceGetDagsUiKey, UseTaskInstanceServiceGetTaskInstancesKeyFn, } from "openapi/queries"; -import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useTogglePause = ({ dagId }: { dagId: string }) => { const queryClient = useQueryClient(); @@ -45,12 +45,8 @@ export const useTogglePause = ({ dagId }: { dagId: string }) => { await Promise.all(queryKeys.map((key) => queryClient.invalidateQueries({ queryKey: key }))); }; - const onError = (error: Error) => { - toaster.create({ - description: error.message, - title: translate("error.title"), - type: "error", - }); + const onError = (error: unknown) => { + createErrorToaster(error, { titleKey: "common:error.title" }, translate); }; return useDagServicePatchDag({ diff --git a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts index 8d1b24afb52..16252358afa 100644 --- a/airflow-core/src/airflow/ui/src/queries/useTrigger.ts +++ b/airflow-core/src/airflow/ui/src/queries/useTrigger.ts @@ -31,6 +31,7 @@ import { import type { TriggerDagRunResponse } from "openapi/requests/types.gen"; import type { DagRunTriggerParams } from "src/components/TriggerDag/types"; import { toaster } from "src/components/ui"; +import { createErrorToaster } from "src/utils"; export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string; onSuccessConfirm: () => void }) => { const queryClient = useQueryClient(); @@ -62,13 +63,9 @@ export const useTrigger = ({ dagId, onSuccessConfirm }: { dagId: string; onSucce } }; - const onError = (_error: Error) => { - toaster.create({ - description: _error.message, - title: translate("triggerDag.toaster.error.title"), - type: "error", - }); - setError(_error); + const onError = (apiError: unknown) => { + createErrorToaster(apiError, { titleKey: "components:triggerDag.toaster.error.title" }, translate); + setError(apiError); }; const { isPending, mutate } = useDagRunServiceTriggerDagRun({ diff --git a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts index 390651b7849..bb00c8466f3 100644 --- a/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts +++ b/airflow-core/src/airflow/ui/src/queries/useUpdateHITLDetail.ts @@ -30,6 +30,7 @@ import { useTaskInstanceServiceGetTaskInstancesKey, } from "openapi/queries"; import { toaster } from "src/components/ui/Toaster"; +import { createErrorToaster } from "src/utils"; import type { HITLResponseParams } from "src/utils/hitl"; export const useUpdateHITLDetail = ({ @@ -64,12 +65,8 @@ export const useUpdateHITLDetail = ({ }); }; - const onError = (_error: Error) => { - toaster.create({ - description: _error.message, - title: translate("response.error"), - type: "error", - }); + const onError = (apiError: unknown) => { + createErrorToaster(apiError, { titleKey: "hitl:response.error" }, translate); }; const { isPending, mutate } = useTaskInstanceServiceUpdateHitlDetail({ diff --git a/airflow-core/src/airflow/ui/src/queryClient.ts b/airflow-core/src/airflow/ui/src/queryClient.ts index 7ebc52c55d5..e465402c308 100644 --- a/airflow-core/src/airflow/ui/src/queryClient.ts +++ b/airflow-core/src/airflow/ui/src/queryClient.ts @@ -16,9 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { QueryClient } from "@tanstack/react-query"; +import { MutationCache, QueryClient } from "@tanstack/react-query"; import { OpenAPI } from "openapi/requests/core/OpenAPI"; +import { toaster } from "src/components/ui"; +import i18n from "src/i18n/config"; +import { getErrorStatus } from "src/utils"; // Dynamically set the base URL for XHR requests based on the meta tag. OpenAPI.BASE = document.querySelector("head>base")?.getAttribute("href") ?? ""; @@ -39,6 +42,28 @@ const retryFunction = (failureCount: number, error: unknown) => { return failureCount < RETRY_COUNT; }; +// Track active 403 toast to prevent duplicates when multiple mutations fail +let active403ToastId: string | undefined; + +// Error handler for 403 (Forbidden) responses on user-initiated actions +const handle403Error = (error: unknown) => { + // Check for 403 (Forbidden) only to avoid interfering with 401 (Auth) logic + const status = getErrorStatus(error); + + if (status === 403) { + // Only show one 403 toast at a time to prevent toast spam + // when multiple mutations fail simultaneously + if (active403ToastId === undefined || !toaster.isActive(active403ToastId)) { + active403ToastId = toaster.create({ + description: i18n.t("errors.forbidden.description"), + title: i18n.t("errors.forbidden.title"), + type: "error", + }); + } + } + // For other errors, let them bubble up to individual mutation handlers +}; + export const client = new QueryClient({ defaultOptions: { mutations: { @@ -52,4 +77,7 @@ export const client = new QueryClient({ staleTime: 5 * 60 * 1000, // 5 minutes }, }, + mutationCache: new MutationCache({ + onError: handle403Error, + }), }); diff --git a/airflow-core/src/airflow/ui/src/utils/errorHandling.ts b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts new file mode 100644 index 00000000000..f19e5d4dc98 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/utils/errorHandling.ts @@ -0,0 +1,90 @@ +/*! + * 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 { TFunction } from "i18next"; + +import { toaster } from "src/components/ui"; + +/** + * Type guard to check if an error has a status property + */ +type ErrorWithStatus = { + message?: string; + response?: { + status?: number; + }; + status?: number; +}; + +/** + * Safely extracts the HTTP status code from an error object + */ +export const getErrorStatus = (error: unknown): number | undefined => { + if (typeof error !== "object" || error === null) { + return undefined; + } + + const errorObj = error as ErrorWithStatus; + + return errorObj.status ?? errorObj.response?.status; +}; + +/** + * Safely extracts the error message from an error object + */ +const getErrorMessage = (error: unknown): string => { + if (typeof error !== "object" || error === null) { + return String(error); + } + + const errorObj = error as ErrorWithStatus; + + return errorObj.message ?? "An error occurred"; +}; + +/** + * Creates an error toaster notification with standardized behavior. + * Skips 403 errors as they are handled by MutationCache. + * + * @param error - The error object to process + * @param options - Configuration options + * @param options.params - Optional parameters for translation interpolation + * @param options.titleKey - The translation key for the error title + * @param translate - The i18next translate function + */ +export const createErrorToaster = ( + error: unknown, + options: { params?: Record<string, string>; titleKey: string }, + translate: TFunction, +): void => { + const status = getErrorStatus(error); + + // Skip 403 errors as they are handled by MutationCache + if (status === 403) { + return; + } + + const message = getErrorMessage(error); + const title = translate(options.titleKey, options.params); + + toaster.create({ + description: message, + title, + type: "error", + }); +}; diff --git a/airflow-core/src/airflow/ui/src/utils/index.ts b/airflow-core/src/airflow/ui/src/utils/index.ts index e9f6904d96e..486cc4bee66 100644 --- a/airflow-core/src/airflow/ui/src/utils/index.ts +++ b/airflow-core/src/airflow/ui/src/utils/index.ts @@ -19,6 +19,7 @@ export { capitalize } from "./capitalize"; export { getDuration, renderDuration } from "./datetimeUtils"; +export { createErrorToaster, getErrorStatus } from "./errorHandling"; export { getMetaKey } from "./getMetaKey"; export { useContainerWidth } from "./useContainerWidth"; export { useFiltersHandler, type FilterableSearchParamsKeys } from "./useFiltersHandler";
