This is an automated email from the ASF dual-hosted git repository.

guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 2bb8665ab fix: surface network-level request failures as user-visible 
toasts (#3424)
2bb8665ab is described below

commit 2bb8665ab6bc71fdecce5aa4a4c0d702069a3f8c
Author: Yuhan <[email protected]>
AuthorDate: Tue Jul 14 14:47:22 2026 +0800

    fix: surface network-level request failures as user-visible toasts (#3424)
---
 .../network-error.mutation-toast.spec.ts           | 212 +++++++++++++++++++++
 src/config/constant.ts                             |   9 +
 src/config/req.ts                                  |  16 +-
 src/routes/routes/detail.$id.tsx                   |   7 -
 4 files changed, 236 insertions(+), 8 deletions(-)

diff --git a/e2e/tests/regression/network-error.mutation-toast.spec.ts 
b/e2e/tests/regression/network-error.mutation-toast.spec.ts
new file mode 100644
index 000000000..db7b560f4
--- /dev/null
+++ b/e2e/tests/regression/network-error.mutation-toast.spec.ts
@@ -0,0 +1,212 @@
+/**
+ * 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.
+ */
+/* eslint-disable playwright/no-wait-for-timeout -- regression test 
stabilization */
+
+// Regression: network-level failures (Admin API unreachable / timeout / CORS
+// preflight failure) reject without an `err.response`, and the global Axios
+// interceptor in src/config/req.ts used to be gated on `err.response` — so
+// every mutation failed with zero user feedback. The interceptor now toasts
+// on responseless errors too, in the one place all request paths flow
+// through: useMutation sites, raw `req.delete` in DeleteResourceBtn, and
+// query refetches alike.
+//
+// Reproduction strategy: aborting the request in the browser triggers exactly
+// the `error.response === undefined` branch on Axios — the same shape as a
+// real backend outage.
+//
+// Also pinned here: the routes detail page previously had its own local
+// onError toast on top of the global one (double toast). It was removed, so
+// a failing route edit must surface exactly ONE alert.
+
+import { routesPom } from '@e2e/pom/routes';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto, uiHasToastMsg } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+import { customAlphabet } from 'nanoid';
+
+import { getConsumerReq, putConsumerReq } from '@/apis/consumers';
+import { putRouteReq } from '@/apis/routes';
+import { API_CONSUMERS, API_ROUTES } from '@/config/constant';
+import type { APISIXType } from '@/types/schema/apisix';
+
+const nanoid = customAlphabet(
+  'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+  8
+);
+
+const deleteTargetUsername = `regnetdel${nanoid()}`;
+const createUsername = `regnetadd${nanoid()}`;
+const timeoutUsername = `regnethang${nanoid()}`;
+const routeId = `regnetroute${nanoid()}`;
+
+test.beforeAll(async () => {
+  await putConsumerReq(e2eReq, {
+    username: deleteTargetUsername,
+  } as APISIXType['ConsumerPut']);
+  await putRouteReq(e2eReq, {
+    id: routeId,
+    name: routeId,
+    uri: `/${routeId}`,
+    methods: ['GET'],
+    upstream: {
+      type: 'roundrobin',
+      nodes: { '127.0.0.1:1980': 1 },
+    },
+  } as unknown as APISIXType['Route']);
+});
+
+test.afterAll(async () => {
+  await e2eReq
+    .delete(`${API_CONSUMERS}/${deleteTargetUsername}`)
+    .catch(() => undefined);
+  await e2eReq
+    .delete(`${API_CONSUMERS}/${createUsername}`)
+    .catch(() => undefined);
+  await e2eReq
+    .delete(`${API_CONSUMERS}/${timeoutUsername}`)
+    .catch(() => undefined);
+  await e2eReq.delete(`${API_ROUTES}/${routeId}`).catch(() => undefined);
+});
+
+test('consumer create shows an error toast when the request gets no response', 
async ({
+  page,
+}) => {
+  // Abort the PUT the add form issues; leave reads untouched.
+  await page.route('**/apisix/admin/consumers', async (route) => {
+    if (route.request().method() === 'PUT') {
+      await route.abort('failed');
+    } else {
+      await route.fallback();
+    }
+  });
+
+  await uiGoto(page, '/consumers/add');
+  await page.getByRole('textbox', { name: 'Username' }).fill(createUsername);
+  await page.getByRole('button', { name: 'Add', exact: true }).click();
+
+  // Before the fix: nothing at all happened. Now the interceptor toasts.
+  await uiHasToastMsg(page, { hasText: /network error/i });
+
+  // The success path navigates to the detail page; a failed create must not.
+  await expect(page).toHaveURL((url) => 
url.pathname.endsWith('/consumers/add'));
+});
+
+test('consumer delete (raw req.delete) shows an error toast when the request 
gets no response', async ({
+  page,
+}) => {
+  // DeleteResourceBtn bypasses react-query entirely (raw req.delete in the
+  // confirm modal), so this pins the interceptor-level coverage that a
+  // MutationCache-only fix would miss.
+  await page.route(
+    (url) =>
+      url.pathname.endsWith(`/apisix/admin/consumers/${deleteTargetUsername}`),
+    async (route) => {
+      if (route.request().method() === 'DELETE') {
+        await route.abort('failed');
+      } else {
+        await route.fallback();
+      }
+    }
+  );
+
+  await uiGoto(page, '/consumers/detail/$username', {
+    username: deleteTargetUsername,
+  });
+  await page.getByRole('button', { name: 'Delete' }).click();
+  await page
+    .getByRole('dialog', { name: 'Delete Consumer' })
+    .getByRole('button', { name: 'Delete' })
+    .click();
+
+  await uiHasToastMsg(page, { hasText: /network error/i });
+
+  // The success path must not have run: no green toast, consumer untouched.
+  await expect(
+    page.getByRole('alert').filter({ hasText: /successfully/i })
+  ).toBeHidden();
+  const consumer = await getConsumerReq(e2eReq, deleteTargetUsername);
+  expect(consumer.value.username).toBe(deleteTargetUsername);
+});
+
+test('route edit shows exactly one error toast when the request gets no 
response', async ({
+  page,
+}) => {
+  // The routes detail page once stacked a local onError toast on top of the
+  // global interceptor toast. Pin the single-toast contract.
+  await page.route(
+    (url) => url.pathname.endsWith(`/apisix/admin/routes/${routeId}`),
+    async (route) => {
+      if (route.request().method() === 'PUT') {
+        await route.abort('failed');
+      } else {
+        await route.fallback();
+      }
+    }
+  );
+
+  await uiGoto(page, '/routes/detail/$id', { id: routeId });
+  await routesPom.isDetailPage(page);
+
+  await page.getByRole('button', { name: 'Edit' }).click();
+  await page.getByLabel('Description').first().fill('network error 
regression');
+  await page.getByRole('button', { name: 'Save' }).click();
+
+  const errorToasts = page
+    .getByRole('alert')
+    .filter({ hasText: /network error/i });
+  await expect(errorToasts.first()).toBeVisible({ timeout: 10000 });
+
+  // Give a hypothetical second toast time to render, then require one only.
+  // Monaco editors contribute empty role=alert live regions, so count only
+  // alerts that carry text (i.e. actual toasts).
+  await page.waitForTimeout(1000);
+  await expect(errorToasts).toHaveCount(1);
+  await expect(page.getByRole('alert').filter({ hasText: /\S/ 
})).toHaveCount(1);
+});
+
+test('consumer create shows a timeout toast when the backend hangs', async ({
+  page,
+}) => {
+  // A hung backend (connection accepted, response never sent) used to keep
+  // the request pending for minutes with zero feedback: axios had no timeout
+  // configured, so nothing rejected until the browser gave up. API_TIMEOUT_MS
+  // now bounds this — the request aborts client-side (ECONNABORTED, no
+  // response) and the interceptor toasts. This test genuinely waits out the
+  // 60s timeout; it is the only honest way to exercise the real code path.
+  test.setTimeout(120_000);
+
+  // Stall the PUT forever: never fulfill, continue, or abort.
+  await page.route('**/apisix/admin/consumers', (route) => {
+    if (route.request().method() === 'PUT') {
+      return new Promise<never>(() => {});
+    }
+    return route.fallback();
+  });
+
+  await uiGoto(page, '/consumers/add');
+  await page.getByRole('textbox', { name: 'Username' }).fill(timeoutUsername);
+  await page.getByRole('button', { name: 'Add', exact: true }).click();
+
+  const timeoutToast = page
+    .getByRole('alert')
+    .filter({ hasText: /timeout of \d+ms exceeded/i });
+  await expect(timeoutToast.first()).toBeVisible({ timeout: 75_000 });
+
+  // Still on the add page — the create did not go through.
+  await expect(page).toHaveURL((url) => 
url.pathname.endsWith('/consumers/add'));
+});
diff --git a/src/config/constant.ts b/src/config/constant.ts
index 4cf5814df..0518bde56 100644
--- a/src/config/constant.ts
+++ b/src/config/constant.ts
@@ -37,5 +37,14 @@ export const API_SSLS = '/ssls';
 export const API_PLUGIN_CONFIGS = '/plugin_configs';
 
 export const SKIP_INTERCEPTOR_HEADER = '__dashboard__skipInterceptor';
+/**
+ * Client-side timeout for Admin API requests. Without it (axios default 0),
+ * a hung backend keeps requests pending until the browser gives up (minutes)
+ * with zero feedback. 60s is deliberately generous: Admin API operations are
+ * metadata CRUD and normally complete in milliseconds, while a timed-out
+ * mutation may still succeed server-side — so false timeouts must stay rare.
+ * Stopgap until the value is runtime-configurable (e.g. via Settings).
+ */
+export const API_TIMEOUT_MS = 60_000;
 export const APPSHELL_HEADER_HEIGHT = 60;
 export const APPSHELL_NAVBAR_WIDTH = 250;
diff --git a/src/config/req.ts b/src/config/req.ts
index 7c94258f2..ed59c49c7 100644
--- a/src/config/req.ts
+++ b/src/config/req.ts
@@ -23,11 +23,12 @@ import { stringify } from 'qs';
 import {
   API_HEADER_KEY,
   API_PREFIX,
+  API_TIMEOUT_MS,
   SKIP_INTERCEPTOR_HEADER,
 } from '@/config/constant';
 import { adminKeyAtom, isSettingsOpenAtom } from '@/stores/global';
 
-export const req = axios.create();
+export const req = axios.create({ timeout: API_TIMEOUT_MS });
 
 req.interceptors.request.use((conf) => {
   conf.paramsSerializer = (p) => {
@@ -93,6 +94,19 @@ req.interceptors.response.use(
       if (res.status === HttpStatusCode.Unauthorized) {
         getDefaultStore().set(isSettingsOpenAtom, true);
       }
+    } else if (!axios.isCancel(err)) {
+      // Network-level failure: no response at all (backend down/unreachable,
+      // timeout, DNS error, CORS preflight failure). Toast here — the single
+      // choke point every request flows through — so raw calls (e.g.
+      // DeleteResourceBtn) and react-query paths all surface feedback.
+      // `matchSkipInterceptor` can't apply: there is no status code to match.
+      // `id` dedupes retries and concurrent failures into one notification.
+      const message = err.message || 'Network Error';
+      notifications.show({
+        id: message,
+        message,
+        color: 'red',
+      });
     }
     return Promise.reject(err);
   }
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index 21fa6e78f..baf0916df 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -111,13 +111,6 @@ const RouteDetailForm = (props: Props) => {
       await refetch();
       setReadOnly(true);
     },
-    onError(err: Error & { response?: { data?: { error_msg?: string } } }) {
-      notifications.show({
-        title: 'Error',
-        message: err.response?.data?.error_msg || err.message || 'Failed to 
update route',
-        color: 'red',
-      });
-    },
   });
 
   const handleCancel = useEditCancelGuard(form, () => setReadOnly(true));

Reply via email to