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

LiteSun 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 0d1a52acf feat: state that a detail page's resource does not exist 
(#3458)
0d1a52acf is described below

commit 0d1a52acf2948190097947c85329b990a01127bb
Author: Yuhan <[email protected]>
AuthorDate: Mon Aug 3 09:25:04 2026 +0800

    feat: state that a detail page's resource does not exist (#3458)
---
 e2e/pom/consumer_groups.ts                         |   2 +-
 e2e/pom/consumers.ts                               |   2 +-
 e2e/pom/credentials.ts                             |   2 +-
 e2e/pom/global_rules.ts                            |   2 +-
 e2e/pom/plugin_configs.ts                          |   2 +-
 e2e/pom/protos.ts                                  |   2 +-
 e2e/pom/routes.ts                                  |   2 +-
 e2e/pom/secrets.ts                                 |   2 +-
 e2e/pom/services.ts                                |   6 +-
 e2e/pom/ssls.ts                                    |   2 +-
 e2e/pom/stream_routes.ts                           |   2 +-
 e2e/pom/upstreams.ts                               |   2 +-
 .../regression/detail.heading-identity.spec.ts     |  71 +++++++
 e2e/tests/regression/detail.not-found.spec.ts      | 215 +++++++++++++++++++++
 .../regression/secrets.detail-fetch-error.spec.ts  |   7 +
 src/components/page/DetailNotFound.tsx             | 139 +++++++++++++
 src/components/page/PageError.tsx                  |  47 +++++
 src/config/global.ts                               |  10 +
 src/config/req.ts                                  |  19 +-
 src/hooks/useResetQueryError.ts                    |  64 ++++++
 src/locales/de/common.json                         |   9 +-
 src/locales/en/common.json                         |   9 +-
 src/locales/es/common.json                         |   9 +-
 src/locales/tr/common.json                         |   9 +-
 src/locales/zh/common.json                         |   9 +-
 src/routes/__root.tsx                              |  32 +--
 src/routes/consumer_groups/detail.$id.tsx          |  11 +-
 .../detail.$username/credentials/detail.$id.tsx    |  11 +-
 src/routes/consumers/detail.$username/index.tsx    |  11 +-
 src/routes/global_rules/detail.$id.tsx             |  11 +-
 src/routes/plugin_configs/detail.$id.tsx           |  11 +-
 src/routes/protos/detail.$id.tsx                   |  11 +-
 src/routes/routes/detail.$id.tsx                   |  11 +-
 src/routes/secrets/detail.$manager.$id.tsx         |  11 +-
 src/routes/services/detail.$id/index.tsx           |  11 +-
 .../services/detail.$id/routes/detail.$routeId.tsx |   6 +
 .../detail.$id/stream_routes/detail.$routeId.tsx   |   9 +-
 src/routes/ssls/detail.$id.tsx                     |  11 +-
 src/routes/stream_routes/detail.$id.tsx            |  14 +-
 src/routes/upstreams/detail.$id.tsx                |  11 +-
 src/utils/error.test.ts                            |  51 +++++
 src/{config/global.ts => utils/error.ts}           |  38 ++--
 42 files changed, 818 insertions(+), 98 deletions(-)

diff --git a/e2e/pom/consumer_groups.ts b/e2e/pom/consumer_groups.ts
index eb446cccd..fa5718a66 100644
--- a/e2e/pom/consumer_groups.ts
+++ b/e2e/pom/consumer_groups.ts
@@ -45,7 +45,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/consumer_groups/detail')
     );
-    const title = page.getByRole('heading', { name: 'Consumer Group Detail' });
+    const title = page.getByRole('heading', { name: /^Consumer Group · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/consumers.ts b/e2e/pom/consumers.ts
index 9b811f1a7..d386dc6d0 100644
--- a/e2e/pom/consumers.ts
+++ b/e2e/pom/consumers.ts
@@ -41,7 +41,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/consumers/detail')
     );
-    const title = page.getByRole('heading', { name: 'Consumer Detail' });
+    const title = page.getByRole('heading', { name: /^Consumer · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/credentials.ts b/e2e/pom/credentials.ts
index 94b37a10c..5ac87ce86 100644
--- a/e2e/pom/credentials.ts
+++ b/e2e/pom/credentials.ts
@@ -46,7 +46,7 @@ const assert = {
       url.pathname.includes('/consumers/detail/') &&
       url.pathname.includes('/credentials/detail/')
     );
-    const title = page.getByRole('heading', { name: 'Credential Detail' });
+    const title = page.getByRole('heading', { name: /^Credential · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/global_rules.ts b/e2e/pom/global_rules.ts
index 872dc35ec..1b2c51122 100644
--- a/e2e/pom/global_rules.ts
+++ b/e2e/pom/global_rules.ts
@@ -45,7 +45,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/global_rules/detail')
     );
-    const title = page.getByRole('heading', { name: 'Global Rule Detail' });
+    const title = page.getByRole('heading', { name: /^Global Rule · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/plugin_configs.ts b/e2e/pom/plugin_configs.ts
index 535fbb140..163045a5a 100644
--- a/e2e/pom/plugin_configs.ts
+++ b/e2e/pom/plugin_configs.ts
@@ -45,7 +45,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/plugin_configs/detail')
     );
-    const title = page.getByRole('heading', { name: 'Plugin Config Detail' });
+    const title = page.getByRole('heading', { name: /^Plugin Config · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/protos.ts b/e2e/pom/protos.ts
index 4ba902731..dd3746061 100644
--- a/e2e/pom/protos.ts
+++ b/e2e/pom/protos.ts
@@ -43,7 +43,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/protos/detail')
     );
-    const title = page.getByRole('heading', { name: 'Proto Detail' });
+    const title = page.getByRole('heading', { name: /^Proto · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/routes.ts b/e2e/pom/routes.ts
index 06bf7c69c..2715cdb11 100644
--- a/e2e/pom/routes.ts
+++ b/e2e/pom/routes.ts
@@ -41,7 +41,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/routes/detail')
     );
-    const title = page.getByRole('heading', { name: 'Route Detail' });
+    const title = page.getByRole('heading', { name: /^Route · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/secrets.ts b/e2e/pom/secrets.ts
index d89b39bf4..b99c737db 100644
--- a/e2e/pom/secrets.ts
+++ b/e2e/pom/secrets.ts
@@ -43,7 +43,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/secrets/detail')
     );
-    const title = page.getByRole('heading', { name: 'Secret Detail' });
+    const title = page.getByRole('heading', { name: /^Secret · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/services.ts b/e2e/pom/services.ts
index 60334c78f..6c5a898a0 100644
--- a/e2e/pom/services.ts
+++ b/e2e/pom/services.ts
@@ -53,7 +53,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/services/detail')
     );
-    const title = page.getByRole('heading', { name: 'Service Detail' });
+    const title = page.getByRole('heading', { name: /^Service · /u });
     await expect(title).toBeVisible();
   },
   // Service routes assertions
@@ -83,7 +83,7 @@ const assert = {
         url.pathname.includes('/services/detail') &&
         url.pathname.includes('/routes/detail')
     );
-    const title = page.getByRole('heading', { name: 'Route Detail' });
+    const title = page.getByRole('heading', { name: /^Route · /u });
     await expect(title).toBeVisible();
   },
   // Service stream routes assertions
@@ -113,7 +113,7 @@ const assert = {
         url.pathname.includes('/services/detail') &&
         url.pathname.includes('/stream_routes/detail')
     );
-    const title = page.getByRole('heading', { name: 'Stream Route Detail' });
+    const title = page.getByRole('heading', { name: /^Stream Route · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/ssls.ts b/e2e/pom/ssls.ts
index 821c68b53..49c49586f 100644
--- a/e2e/pom/ssls.ts
+++ b/e2e/pom/ssls.ts
@@ -39,7 +39,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/ssls/detail')
     );
-    const title = page.getByRole('heading', { name: 'SSL Detail' });
+    const title = page.getByRole('heading', { name: /^SSL · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/pom/stream_routes.ts b/e2e/pom/stream_routes.ts
index fa2077f61..3c80c902a 100644
--- a/e2e/pom/stream_routes.ts
+++ b/e2e/pom/stream_routes.ts
@@ -45,7 +45,7 @@ const assert = {
       { timeout: 20000 }
     ).toHaveURL((url) => url.pathname.includes('/stream_routes/detail'));
     const title = page.getByRole('heading', {
-      name: 'Stream Route Detail',
+      name: /^Stream Route · /u,
     });
     await expect(title).toBeVisible({ timeout: 30000 });
   },
diff --git a/e2e/pom/upstreams.ts b/e2e/pom/upstreams.ts
index 32f8e7f88..236d181ee 100644
--- a/e2e/pom/upstreams.ts
+++ b/e2e/pom/upstreams.ts
@@ -43,7 +43,7 @@ const assert = {
     await expect(page).toHaveURL((url) =>
       url.pathname.includes('/upstreams/detail')
     );
-    const title = page.getByRole('heading', { name: 'Upstream Detail' });
+    const title = page.getByRole('heading', { name: /^Upstream · /u });
     await expect(title).toBeVisible();
   },
 };
diff --git a/e2e/tests/regression/detail.heading-identity.spec.ts 
b/e2e/tests/regression/detail.heading-identity.spec.ts
new file mode 100644
index 000000000..a32e85d56
--- /dev/null
+++ b/e2e/tests/regression/detail.heading-identity.spec.ts
@@ -0,0 +1,71 @@
+/**
+ * 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 { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// #3453 item 2: every detail heading read "Route Detail", so identifying
+// which of a dozen similarly-named routes you had opened meant reading the
+// id field further down the form. #3441 fixed the browser-tab title; the
+// on-page heading stayed generic.
+
+const ROUTE_ID = 'reg-heading-identity';
+// A name distinct from the id, so an assertion on the heading cannot pass
+// by accidentally matching the name.
+const ROUTE_NAME = 'reg heading identity route';
+
+const clean = () => safeClean(() => deleteAllRoutes(e2eReq));
+
+test.beforeAll(async () => {
+  await clean();
+  await putRouteReq(e2eReq, {
+    id: ROUTE_ID,
+    name: ROUTE_NAME,
+    uri: '/reg-heading-identity',
+    methods: ['GET'],
+    // A route needs one of plugins / upstream / service_id alongside its
+    // uri, or the Admin API rejects it.
+    upstream: { type: 'roundrobin', nodes: { 'heading.local:80': 1 } },
+  } as APISIXType['Route']);
+});
+
+test.afterAll(clean);
+
+test('the detail heading names the resource, and edit mode is unchanged', 
async ({
+  page,
+}) => {
+  await uiGoto(page, '/routes/detail/$id', { id: ROUTE_ID });
+
+  // Scoped to the page body: Mantine renders a modal's title as an `h2` too
+  // (the settings modal lets itself in uninvited — hence
+  // `uiEnsureSettingsClosed`), and an unscoped level-2 lookup would then
+  // resolve two elements and die on strict mode instead of on the assertion
+  // below.
+  const heading = page.getByRole('main').getByRole('heading', { level: 2 });
+  await expect(heading).toHaveText(`Route · ${ROUTE_ID}`);
+
+  // The identity belongs to the read-only heading only. Edit mode has its
+  // own string (`info.edit.title`); wiring the new key into that branch by
+  // mistake would leave the user with no "you are editing" signal.
+  await page.getByRole('button', { name: 'Edit', exact: true }).click();
+  await expect(heading).toHaveText('Edit Route');
+});
diff --git a/e2e/tests/regression/detail.not-found.spec.ts 
b/e2e/tests/regression/detail.not-found.spec.ts
new file mode 100644
index 000000000..4fecc04c7
--- /dev/null
+++ b/e2e/tests/regression/detail.not-found.spec.ts
@@ -0,0 +1,215 @@
+/**
+ * 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 { routesPom } from '@e2e/pom/routes';
+import { safeClean } from '@e2e/utils/clean';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { uiGoto } from '@e2e/utils/ui';
+import { expect } from '@playwright/test';
+
+import { deleteAllConsumers } from '@/apis/consumers';
+import { deleteAllRoutes, putRouteReq } from '@/apis/routes';
+import { deleteAllServices, putServiceReq } from '@/apis/services';
+import type { APISIXType } from '@/types/schema/apisix';
+
+// #3453 item 1. Opening a detail URL for an id that is not there used to be
+// a dead end: the 404 was retried three times (~7s), then the ROOT error
+// component replaced the whole app shell with axios's "Request failed with
+// status code 404", plus a red toast carrying etcd's wording, "Key not
+// found". In a BACKGROUND tab it never resolved at all — query-core's
+// retryer pauses while `document.visibilityState === 'hidden'`
+// (retryer.js `canContinue` -> focusManager), and a paused query never
+// reaches `error`, so nothing was ever thrown and the page sat on its
+// loading skeleton with Edit and Delete still clickable.
+//
+// The request COUNT below is the load-bearing assertion: a not-found panel
+// can render correctly while the retry storm survives untouched, and the
+// storm is what makes the state unreachable in a background tab.
+
+const MISSING_ROUTE_ID = 'reg-not-found-route';
+const MISSING_USERNAME = 'reg_not_found_consumer';
+const SERVICE_ID = 'reg-not-found-service';
+const MISSING_NESTED_ROUTE_ID = 'reg-not-found-nested-route';
+const RECREATED_ROUTE_ID = 'reg-not-found-recreated-route';
+const RECREATED_ROUTE_NAME = 'reg not found recreated route';
+
+const clean = () =>
+  safeClean(async () => {
+    await deleteAllRoutes(e2eReq);
+    await deleteAllConsumers(e2eReq);
+    await deleteAllServices(e2eReq);
+  });
+
+test.beforeAll(async () => {
+  await clean();
+  // The service must exist: the nested case asserts the back link returns
+  // to a real service's routes tab.
+  await putServiceReq(e2eReq, {
+    id: SERVICE_ID,
+    name: 'reg not found service',
+  });
+});
+
+test.afterAll(clean);
+
+test('a missing route is requested once, states its absence, and offers a way 
back', async ({
+  page,
+}) => {
+  const requests: string[] = [];
+  page.on('request', (r) => {
+    if (r.url().includes(`/apisix/admin/routes/${MISSING_ROUTE_ID}`)) {
+      requests.push(r.url());
+    }
+  });
+
+  await uiGoto(page, '/routes/detail/$id', { id: MISSING_ROUTE_ID });
+
+  // Waiting for the settled state is what makes the request count
+  // deterministic — no sleeps.
+  await expect(
+    page.getByText('This Route does not exist. It may have been deleted.')
+  ).toBeVisible();
+
+  // Load-bearing: the panel above can render correctly while the retry
+  // storm survives, and the storm is what makes this state unreachable in
+  // a background tab.
+  expect(requests).toHaveLength(1);
+
+  // etcd's raw "Key not found" must not appear alongside the panel.
+  await expect(page.getByRole('alert')).toHaveCount(0);
+
+  // The heading identifies the resource even though it was never fetched.
+  // Scoped to the page body: a Mantine modal title is an `h2` as well, so an
+  // unscoped level-2 lookup would resolve two elements and die on strict
+  // mode rather than on this assertion.
+  await expect(
+    page.getByRole('main').getByRole('heading', { level: 2 })
+  ).toHaveText(`Route · ${MISSING_ROUTE_ID}`);
+
+  // Edit and Delete must be absent, not merely disabled: acting on a
+  // resource that is not there cannot succeed.
+  await expect(
+    page.getByRole('button', { name: 'Edit', exact: true })
+  ).toHaveCount(0);
+  await expect(
+    page.getByRole('button', { name: 'Delete', exact: true })
+  ).toHaveCount(0);
+
+  // The nav must survive — the old behaviour replaced the whole app shell.
+  // `exact` because an accessible-name substring also matches the
+  // "Stream Routes" nav item.
+  await expect(
+    page.getByRole('link', { name: 'Routes', exact: true })
+  ).toBeVisible();
+
+  await page.getByRole('button', { name: 'Back to Routes' }).click();
+  await expect(page.getByRole('table')).toBeVisible();
+});
+
+test('the not-found state does not outlive the resource being re-created', 
async ({
+  page,
+}) => {
+  // The panel must not assert something false about the user's data. A 404
+  // puts the detail query into `error`, and while react-query's error-reset
+  // boundary is un-reset `errorBoundaryUtils` sets `retryOnMount = false`,
+  // after which `queryObserver.shouldLoadOnMount` refuses to fetch an
+  // errored query. Left alone, the cached 404 is re-thrown on every later
+  // mount for the whole `gcTime` (5 minutes) — so a re-created resource
+  // still reads as deleted. Resetting the boundary is not enough on its own:
+  // the flag is cleared again by the next query mount, which the list page
+  // below performs. See `useResetQueryError`.
+  //
+  // Every navigation below stays INSIDE the SPA on purpose. `uiGoto` is a
+  // full page load, which builds a fresh QueryClient with an empty cache and
+  // would make this test pass against the very bug it exists to catch.
+  await uiGoto(page, '/routes/detail/$id', { id: RECREATED_ROUTE_ID });
+  const notFound = page.getByText(
+    'This Route does not exist. It may have been deleted.'
+  );
+  await expect(notFound).toBeVisible();
+
+  // The user re-creates the resource under the same id while the tab stays
+  // open — the accidental-delete recovery this regression is about. Seeding
+  // it BEFORE the list is ever opened keeps the test free of timing: the
+  // list query has never run in this session, so its first mount fetches.
+  // (A list already on screen would not refetch on a quick re-entry —
+  // react-query's `ensureSuspenseTimers` floors a suspense query's staleTime
+  // at 1s.)
+  await putRouteReq(e2eReq, {
+    id: RECREATED_ROUTE_ID,
+    name: RECREATED_ROUTE_NAME,
+    uri: '/reg-not-found-recreated',
+    methods: ['GET'],
+    upstream: { type: 'roundrobin', nodes: { 'recreated.local:80': 1 } },
+  } as APISIXType['Route']);
+
+  await page.getByRole('button', { name: 'Back to Routes' }).click();
+  await routesPom.isIndexPage(page);
+  const row = page.locator('tr').filter({ hasText: RECREATED_ROUTE_ID });
+  await expect(row).toBeVisible();
+
+  // Reached by clicking, not by URL: same session, same query cache.
+  await row.getByRole('button', { name: 'View' }).click();
+
+  await routesPom.isDetailPage(page);
+  await expect(notFound).toHaveCount(0);
+  // The form rendered, with the re-created resource's data in it.
+  await expect(page.getByLabel('Name', { exact: true }).first()).toHaveValue(
+    RECREATED_ROUTE_NAME
+  );
+  await expect(
+    page.getByRole('button', { name: 'Edit', exact: true })
+  ).toBeVisible();
+});
+
+test('a missing consumer is keyed by username, not id', async ({ page }) => {
+  // A second param shape proves the wiring is not route-specific. The
+  // consumer is deliberately never created — that is the point.
+  await uiGoto(page, '/consumers/detail/$username', {
+    username: MISSING_USERNAME,
+  });
+
+  await expect(
+    page.getByText('This Consumer does not exist. It may have been deleted.')
+  ).toBeVisible();
+  await expect(
+    page.getByRole('main').getByRole('heading', { level: 2 })
+  ).toHaveText(`Consumer · ${MISSING_USERNAME}`);
+  await page.getByRole('button', { name: 'Back to Consumers' }).click();
+  await expect(page.getByRole('table')).toBeVisible();
+});
+
+test('a missing route under a service links back to that service', async ({
+  page,
+}) => {
+  // The nested case is the only one whose back link needs a parent param,
+  // so it is the only one that exercises the params passthrough.
+  await uiGoto(page, '/services/detail/$id/routes/detail/$routeId', {
+    id: SERVICE_ID,
+    routeId: MISSING_NESTED_ROUTE_ID,
+  });
+
+  await expect(
+    page.getByText('This Route does not exist. It may have been deleted.')
+  ).toBeVisible();
+  await page.getByRole('button', { name: 'Back to Routes' }).click();
+  // The path must be the service's routes tab, not `/routes` and not
+  // `/services`; the list adds its own pagination search params.
+  await expect(page).toHaveURL(
+    new RegExp(`/services/detail/${SERVICE_ID}/routes(\\?|$)`)
+  );
+});
diff --git a/e2e/tests/regression/secrets.detail-fetch-error.spec.ts 
b/e2e/tests/regression/secrets.detail-fetch-error.spec.ts
index 0abd29028..b11a015ff 100644
--- a/e2e/tests/regression/secrets.detail-fetch-error.spec.ts
+++ b/e2e/tests/regression/secrets.detail-fetch-error.spec.ts
@@ -90,6 +90,13 @@ test('secret detail shows the error page, not a blank 
editable form, when its fe
   await expect(page.getByRole('button', { name: 'Edit' })).toBeHidden();
   await expect(page.getByLabel('URI')).toBeHidden();
 
+  // The button must recover, not merely render. Task 3 moved which boundary
+  // catches this error from the root to the route, so `router.invalidate()`
+  // plus the query-error reset are exercised at a level they never were.
+  await page.unrouteAll({ behavior: 'ignoreErrors' });
+  await page.getByRole('button', { name: 'Retry' }).click();
+  await expect(page.getByLabel('URI')).toBeVisible({ timeout: 15_000 });
+
   // The real secret is untouched on the backend.
   const secret = await getSecretReq(e2eReq, {
     manager: 'vault',
diff --git a/src/components/page/DetailNotFound.tsx 
b/src/components/page/DetailNotFound.tsx
new file mode 100644
index 000000000..592b5f9c4
--- /dev/null
+++ b/src/components/page/DetailNotFound.tsx
@@ -0,0 +1,139 @@
+/**
+ * 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 { Stack, Text } from '@mantine/core';
+import {
+  type ErrorComponentProps,
+  type LinkProps,
+  useParams,
+} from '@tanstack/react-router';
+import type { ComponentType } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { RouteLinkBtn } from '@/components/Btn';
+import { PageError } from '@/components/page/PageError';
+import PageHeader from '@/components/page/PageHeader';
+import type { Resources } from '@/config/i18n';
+import { useResetQueryError } from '@/hooks/useResetQueryError';
+import type { FileRoutesByTo } from '@/routeTree.gen';
+import { isNotFoundError } from '@/utils/error';
+
+/** Every `sources` key has a matching top-level object carrying `singular`. */
+export type DetailResource = keyof Resources['en']['common']['sources'];
+
+export type DetailNotFoundProps = {
+  /**
+   * The identifier from the URL. Deliberately not the resource's `name`:
+   * the name lives only in the response, which by definition never
+   * arrived, and `name` is optional in APISIX anyway.
+   */
+  id: string;
+  resource: DetailResource;
+  to: keyof FileRoutesByTo;
+  /** the 404 that produced this panel; see `useResetQueryError` */
+  error: unknown;
+} & Pick<LinkProps, 'params'>;
+
+export const DetailNotFound = ({
+  id,
+  resource,
+  to,
+  params,
+  error,
+}: DetailNotFoundProps) => {
+  const { t } = useTranslation();
+  const singular = t(`${resource}.singular`);
+  // The same call `PageError` makes, so the app's two error surfaces cannot
+  // drift: react-query caches the failure and replays it on later mounts.
+  // Without this, deleting a resource, opening its URL, then re-creating it
+  // under the same id leaves this panel claiming it does not exist.
+  useResetQueryError(error);
+  return (
+    <>
+      {/*
+        No `extra`: Edit and Delete are structurally absent rather than
+        conditionally hidden, so nothing can regress them back into a page
+        whose resource does not exist.
+      */}
+      <PageHeader title={t('info.detail.titleWithId', { name: singular, id })} 
/>
+      <Stack align="flex-start" gap="md">
+        <Text>{t('error.notFound.description', { name: singular })}</Text>
+        <RouteLinkBtn to={to} params={params} size="compact-sm" 
variant="light">
+          {t('error.notFound.back', { name: t(`sources.${resource}`) })}
+        </RouteLinkBtn>
+      </Stack>
+    </>
+  );
+};
+
+export type DetailErrorComponentOptions = {
+  /** which URL param carries this resource's identifier */
+  idParam: string;
+  resource: DetailResource;
+  /** the list to return to */
+  to: keyof FileRoutesByTo;
+  /**
+   * handles everything that is not a 404; defaults to the same generic
+   * error page (message plus Retry) the root error component shows, so
+   * intercepting 404s here does not cost the other failures their Retry
+   */
+  fallback?: ComponentType<ErrorComponentProps>;
+};
+
+/**
+ * A route `errorComponent` that turns a 404 into an explicit not-found
+ * state and defers everything else.
+ *
+ * Route-level rather than app-level on purpose: the root error component
+ * replaces the whole shell, losing the nav and any way back to the list.
+ * A child route's error component replaces only that route's element
+ * inside the root `<Outlet>`.
+ */
+// A factory, not a component, so react-refresh cannot treat it as one; it
+// belongs beside the panel it builds rather than in a file of its own.
+// eslint-disable-next-line react-refresh/only-export-components
+export const genDetailErrorComponent = ({
+  idParam,
+  resource,
+  to,
+  fallback: Fallback = PageError,
+}: DetailErrorComponentOptions) => {
+  // Named and capitalised so eslint-plugin-react-hooks recognises it as a
+  // component and accepts the hook call below.
+  const DetailErrorComponent = (props: ErrorComponentProps) => {
+    // `strict: false` avoids threading a per-route `from` literal through
+    // the factory; the same escape hatch `services/detail.$id.tsx` uses.
+    const urlParams = useParams({ strict: false }) as Record<string, string>;
+    if (!isNotFoundError(props.error)) {
+      return <Fallback {...props} />;
+    }
+    return (
+      <DetailNotFound
+        id={urlParams[idParam] ?? ''}
+        resource={resource}
+        to={to}
+        error={props.error}
+        // Passing every URL param covers the nested routes, whose back
+        // link interpolates a parent's `$id`; extra keys are ignored when
+        // the target path has no placeholder for them. The router would
+        // also inherit those params from the current match, but stating
+        // them keeps the target independent of that implicit behaviour.
+        params={urlParams}
+      />
+    );
+  };
+  return DetailErrorComponent;
+};
diff --git a/src/components/page/PageError.tsx 
b/src/components/page/PageError.tsx
new file mode 100644
index 000000000..45a52bcb8
--- /dev/null
+++ b/src/components/page/PageError.tsx
@@ -0,0 +1,47 @@
+/**
+ * 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, Code, Stack, Text } from '@mantine/core';
+import { type ErrorComponentProps, useRouter } from '@tanstack/react-router';
+import { useTranslation } from 'react-i18next';
+
+import { useResetQueryError } from '@/hooks/useResetQueryError';
+
+/**
+ * The app's generic error state: the message plus a Retry that re-runs the
+ * failed load. Shared so a route-level error component offers the same
+ * affordance the root one does (#3418) — a detail route that handled its
+ * own errors with TanStack's bare `ErrorComponent` would silently drop the
+ * Retry button.
+ */
+export const PageError = (props: ErrorComponentProps) => {
+  const { error } = props;
+  const { t } = useTranslation();
+  const router = useRouter();
+  // detail pages throw from useSuspenseQuery during render; unless the
+  // query error-reset boundary is reset, react-query re-throws the cached
+  // error on remount and the Retry button would loop back here
+  useResetQueryError(error);
+  return (
+    <Stack align="center" justify="center" mih="60vh" gap="md" p="xl">
+      <Text fw={700} size="lg">
+        {t('error.title')}
+      </Text>
+      <Code block>{error.message}</Code>
+      <Button onClick={() => router.invalidate()}>{t('error.retry')}</Button>
+    </Stack>
+  );
+};
diff --git a/src/config/global.ts b/src/config/global.ts
index c1dfad63b..9b965c8db 100644
--- a/src/config/global.ts
+++ b/src/config/global.ts
@@ -19,6 +19,7 @@ import { createRouter } from '@tanstack/react-router';
 import { HttpStatusCode, isAxiosError } from 'axios';
 
 import { routeTree } from '@/routeTree.gen';
+import { isNotFoundError } from '@/utils/error';
 
 import { BASE_PATH } from './constant';
 
@@ -38,6 +39,15 @@ export const queryClient = new QueryClient({
         ) {
           return false;
         }
+        // A 404 means the resource does not exist; no number of retries
+        // changes that. This is also load-bearing for the detail pages'
+        // not-found state: a retry sequence PAUSES while the tab is hidden
+        // (query-core retryer `canContinue` -> focusManager), and a paused
+        // query never reaches `error`, so nothing is thrown and the page
+        // would sit on its loading skeleton indefinitely.
+        if (isNotFoundError(error)) {
+          return false;
+        }
         return failureCount < 3;
       },
     },
diff --git a/src/config/req.ts b/src/config/req.ts
index ed59c49c7..fb15c6de2 100644
--- a/src/config/req.ts
+++ b/src/config/req.ts
@@ -27,6 +27,7 @@ import {
   SKIP_INTERCEPTOR_HEADER,
 } from '@/config/constant';
 import { adminKeyAtom, isSettingsOpenAtom } from '@/stores/global';
+import { isNotFoundError } from '@/utils/error';
 
 export const req = axios.create({ timeout: API_TIMEOUT_MS });
 
@@ -81,11 +82,19 @@ req.interceptors.response.use(
       const res = err.response as AxiosResponse<APISIXRespErr>;
       const d = res.data;
       const message = d?.error_msg || d?.message || `Error status: 
${res.status}`;
-      notifications.show({
-        id: message,
-        message,
-        color: 'red',
-      });
+      // A 404 on a read is data — "this does not exist" — which the page
+      // itself now renders; the gateway's raw wording ("Key not found") as
+      // a red toast is noise. A 404 from a write is a real surprise the
+      // user must hear about, so only reads are quiet.
+      const isMissingOnRead =
+        isNotFoundError(err) && err.config?.method?.toUpperCase() === 'GET';
+      if (!isMissingOnRead) {
+        notifications.show({
+          id: message,
+          message,
+          color: 'red',
+        });
+      }
       // Requires to enter admin key at 401.
       // Note: do NOT resolve with fabricated data here — callers must take
       // their normal error path. Resolving `{ data: {} }` made a 401'd
diff --git a/src/hooks/useResetQueryError.ts b/src/hooks/useResetQueryError.ts
new file mode 100644
index 000000000..459ba53ff
--- /dev/null
+++ b/src/hooks/useResetQueryError.ts
@@ -0,0 +1,64 @@
+/**
+ * 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 {
+  useQueryClient,
+  useQueryErrorResetBoundary,
+} from '@tanstack/react-query';
+import { useEffect } from 'react';
+
+import { isNotFoundError } from '@/utils/error';
+
+/**
+ * Stops a failure that is already on screen from being replayed later.
+ *
+ * Pages throw from `useSuspenseQuery` during render, and react-query keeps
+ * the failure: while its error-reset boundary is un-reset,
+ * `errorBoundaryUtils.ensurePreventErrorBoundaryRetry` sets
+ * `retryOnMount = false`, and `queryObserver.shouldLoadOnMount` then refuses
+ * to fetch an errored query — so the next mount re-throws the cached error
+ * instead of retrying the load, for as long as the entry lives (`gcTime`,
+ * five minutes past its last observer).
+ *
+ * Every error surface must call this. Skipping it makes that surface a dead
+ * end: its Retry loops straight back to itself.
+ *
+ * A 404 needs more than the reset. `reset()` only flips a flag that the very
+ * next query mount clears again (`useBaseQuery` ->
+ * `useClearResetErrorBoundary`) — one trip through any list page does it —
+ * and a stale 404 is not merely an unhelpful screen: it is a false statement
+ * about the user's data. Delete a resource, open its URL, re-create it under
+ * the same id, and the panel would still call it deleted. So the cache entry
+ * that carries this 404 is dropped outright, and the next mount starts over.
+ *
+ * Only 404s. Every other failure keeps its entry on purpose: recovering from
+ * a wrong admin key depends on `SettingsModal`'s debounced
+ * `refetchQueries()` reaching exactly these observer-less error-state
+ * queries, and `Query.isDisabled()` excludes any query that has not
+ * completed a fetch — so a dropped (or `resetQueries`-reset) entry is one
+ * that refresh can never reach again. A 404 is not what an admin key fixes.
+ */
+export const useResetQueryError = (error: unknown) => {
+  const queryClient = useQueryClient();
+  const queryErrorResetBoundary = useQueryErrorResetBoundary();
+  useEffect(() => {
+    queryErrorResetBoundary.reset();
+    if (!isNotFoundError(error)) return;
+    queryClient.removeQueries({
+      predicate: (query) => query.state.error === error,
+    });
+  }, [error, queryClient, queryErrorResetBoundary]);
+};
diff --git a/src/locales/de/common.json b/src/locales/de/common.json
index c5a89eb8f..8084c3059 100644
--- a/src/locales/de/common.json
+++ b/src/locales/de/common.json
@@ -310,7 +310,8 @@
       "confirm": "Möchten Sie <0>{{name}}</0> wirklich löschen?"
     },
     "detail": {
-      "title": "{{name}} Detail"
+      "title": "{{name}} Detail",
+      "titleWithId": "{{name}} · {{id}}"
     },
     "edit": {
       "success": "{{name}} erfolgreich bearbeitet",
@@ -384,7 +385,11 @@
   },
   "error": {
     "title": "Etwas ist schiefgelaufen",
-    "retry": "Erneut versuchen"
+    "retry": "Erneut versuchen",
+    "notFound": {
+      "description": "{{name}} existiert nicht oder wurde gelöscht.",
+      "back": "Zurück zu {{name}}"
+    }
   },
   "a11y": {
     "openSettings": "Open settings",
diff --git a/src/locales/en/common.json b/src/locales/en/common.json
index 7271cdfef..9b0c9d979 100644
--- a/src/locales/en/common.json
+++ b/src/locales/en/common.json
@@ -310,7 +310,8 @@
       "confirm": "Are you sure you want to delete <0>{{name}}</0>?"
     },
     "detail": {
-      "title": "{{name}} Detail"
+      "title": "{{name}} Detail",
+      "titleWithId": "{{name}} · {{id}}"
     },
     "edit": {
       "success": "Edit {{name}} Successfully",
@@ -384,7 +385,11 @@
   },
   "error": {
     "title": "Something went wrong",
-    "retry": "Retry"
+    "retry": "Retry",
+    "notFound": {
+      "description": "This {{name}} does not exist. It may have been deleted.",
+      "back": "Back to {{name}}"
+    }
   },
   "a11y": {
     "openSettings": "Open settings",
diff --git a/src/locales/es/common.json b/src/locales/es/common.json
index a0c7c8fe0..267d1dfea 100644
--- a/src/locales/es/common.json
+++ b/src/locales/es/common.json
@@ -310,7 +310,8 @@
       "confirm": "¿Está seguro de que desea eliminar <0>{{name}}</0>?"
     },
     "detail": {
-      "title": "Detalle de {{name}}"
+      "title": "Detalle de {{name}}",
+      "titleWithId": "{{name}} · {{id}}"
     },
     "edit": {
       "success": "Editado {{name}} con éxito",
@@ -384,7 +385,11 @@
   },
   "error": {
     "title": "Algo salió mal",
-    "retry": "Reintentar"
+    "retry": "Reintentar",
+    "notFound": {
+      "description": "{{name}} no existe o se ha eliminado.",
+      "back": "Volver a {{name}}"
+    }
   },
   "a11y": {
     "openSettings": "Open settings",
diff --git a/src/locales/tr/common.json b/src/locales/tr/common.json
index 47e74bc5a..97f24efe0 100644
--- a/src/locales/tr/common.json
+++ b/src/locales/tr/common.json
@@ -310,7 +310,8 @@
       "confirm": "<0>{{name}}</0> öğesini silmek istediğinizden emin misiniz?"
     },
     "detail": {
-      "title": "{{name}} Detay"
+      "title": "{{name}} Detay",
+      "titleWithId": "{{name}} · {{id}}"
     },
     "edit": {
       "success": "{{name}} başarıyla güncellendi",
@@ -384,7 +385,11 @@
   },
   "error": {
     "title": "Bir şeyler ters gitti",
-    "retry": "Yeniden dene"
+    "retry": "Yeniden dene",
+    "notFound": {
+      "description": "{{name}} bulunamadı. Silinmiş olabilir.",
+      "back": "{{name}} listesine dön"
+    }
   },
   "a11y": {
     "openSettings": "Open settings",
diff --git a/src/locales/zh/common.json b/src/locales/zh/common.json
index 9f5714f18..7ac66953c 100644
--- a/src/locales/zh/common.json
+++ b/src/locales/zh/common.json
@@ -310,7 +310,8 @@
       "confirm": "确定要删除 <0>{{name}}</0> 吗?"
     },
     "detail": {
-      "title": "{{name}} 详情"
+      "title": "{{name}} 详情",
+      "titleWithId": "{{name}} · {{id}}"
     },
     "edit": {
       "success": "编辑 {{name}} 成功",
@@ -384,7 +385,11 @@
   },
   "error": {
     "title": "出错了",
-    "retry": "重试"
+    "retry": "重试",
+    "notFound": {
+      "description": "该{{name}}不存在,可能已被删除。",
+      "back": "返回{{name}}"
+    }
   },
   "a11y": {
     "openSettings": "打开设置",
diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx
index d9cdbe689..6834d353a 100644
--- a/src/routes/__root.tsx
+++ b/src/routes/__root.tsx
@@ -14,23 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { AppShell, Button, Code, Stack, Text } from '@mantine/core';
+import { AppShell } from '@mantine/core';
 import { useDisclosure } from '@mantine/hooks';
-import { useQueryErrorResetBoundary } from '@tanstack/react-query';
 import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
 import {
   createRootRoute,
   type ErrorComponentProps,
   HeadContent,
   Outlet,
-  useRouter,
 } from '@tanstack/react-router';
 import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
-import { useEffect } from 'react';
-import { I18nextProvider, useTranslation } from 'react-i18next';
+import { I18nextProvider } from 'react-i18next';
 
 import { Header } from '@/components/Header';
 import { Navbar } from '@/components/Navbar';
+import { PageError } from '@/components/page/PageError';
 import { SettingsModal } from '@/components/page/SettingsModal';
 import {
   APPSHELL_HEADER_HEIGHT,
@@ -81,28 +79,6 @@ const Root = () => {
   );
 };
 
-const RootErrorContent = (props: ErrorComponentProps) => {
-  const { error } = props;
-  const { t } = useTranslation();
-  const router = useRouter();
-  // detail pages throw from useSuspenseQuery during render; unless the
-  // query error-reset boundary is reset, react-query re-throws the cached
-  // error on remount and the Retry button would loop back here
-  const queryErrorResetBoundary = useQueryErrorResetBoundary();
-  useEffect(() => {
-    queryErrorResetBoundary.reset();
-  }, [queryErrorResetBoundary]);
-  return (
-    <Stack align="center" justify="center" mih="60vh" gap="md" p="xl">
-      <Text fw={700} size="lg">
-        {t('error.title')}
-      </Text>
-      <Code block>{error.message}</Code>
-      <Button onClick={() => router.invalidate()}>{t('error.retry')}</Button>
-    </Stack>
-  );
-};
-
 /**
  * Loader/render failures land here when no child route handles them.
  * The settings modal must stay mounted: on a fresh install every request
@@ -110,7 +86,7 @@ const RootErrorContent = (props: ErrorComponentProps) => {
  */
 const RootError = (props: ErrorComponentProps) => (
   <I18nextProvider i18n={i18n}>
-    <RootErrorContent {...props} />
+    <PageError {...props} />
     <SettingsModal />
   </I18nextProvider>
 );
diff --git a/src/routes/consumer_groups/detail.$id.tsx 
b/src/routes/consumer_groups/detail.$id.tsx
index ece24f68f..535d1ff07 100644
--- a/src/routes/consumer_groups/detail.$id.tsx
+++ b/src/routes/consumer_groups/detail.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartPluginConfig } from 
'@/components/form-slice/FormPartPluginConf
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_CONSUMER_GROUPS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -119,7 +120,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('consumerGroups.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('consumerGroups.singular') 
}),
+          title: t('info.detail.titleWithId', {
+            name: t('consumerGroups.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -153,4 +157,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/consumer_groups/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'consumerGroups',
+    to: '/consumer_groups',
+  }),
 });
diff --git a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx 
b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
index 89aafafd8..e413ed495 100644
--- a/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
+++ b/src/routes/consumers/detail.$username/credentials/detail.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartCredential } from 
'@/components/form-slice/FormPartCredential';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_CREDENTIALS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -126,7 +127,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('credentials.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('credentials.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('credentials.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -161,4 +165,9 @@ export const Route = createFileRoute(
   '/consumers/detail/$username/credentials/detail/$id'
 )({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'credentials',
+    to: '/consumers/detail/$username/credentials',
+  }),
 });
diff --git a/src/routes/consumers/detail.$username/index.tsx 
b/src/routes/consumers/detail.$username/index.tsx
index 44751eab4..54c6e5131 100644
--- a/src/routes/consumers/detail.$username/index.tsx
+++ b/src/routes/consumers/detail.$username/index.tsx
@@ -35,6 +35,7 @@ import { FormPartConsumer } from 
'@/components/form-slice/FormPartConsumer';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_CONSUMERS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -122,7 +123,10 @@ const ConsumerDetailTab = () => {
       <PageHeader
         title={t('info.edit.title', { name: t('consumers.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('consumers.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('consumers.singular'),
+            id: username,
+          }),
           extra: (
             <Group>
               <Button
@@ -153,4 +157,9 @@ const ConsumerDetailTab = () => {
 
 export const Route = createFileRoute('/consumers/detail/$username/')({
   component: ConsumerDetailTab,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'username',
+    resource: 'consumers',
+    to: '/consumers',
+  }),
 });
diff --git a/src/routes/global_rules/detail.$id.tsx 
b/src/routes/global_rules/detail.$id.tsx
index 5c6e65f2c..bcf097a89 100644
--- a/src/routes/global_rules/detail.$id.tsx
+++ b/src/routes/global_rules/detail.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartGlobalRules } from 
'@/components/form-slice/FormPartGlobalRules
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_GLOBAL_RULES } from '@/config/constant';
 import { req } from '@/config/req';
@@ -111,7 +112,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('globalRules.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('globalRules.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('globalRules.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -141,4 +145,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/global_rules/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'globalRules',
+    to: '/global_rules',
+  }),
 });
diff --git a/src/routes/plugin_configs/detail.$id.tsx 
b/src/routes/plugin_configs/detail.$id.tsx
index 4cf8304a6..9f152b92f 100644
--- a/src/routes/plugin_configs/detail.$id.tsx
+++ b/src/routes/plugin_configs/detail.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartPluginConfig } from 
'@/components/form-slice/FormPartPluginConf
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_PLUGIN_CONFIGS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -117,7 +118,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('pluginConfigs.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('pluginConfigs.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('pluginConfigs.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -151,4 +155,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/plugin_configs/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'pluginConfigs',
+    to: '/plugin_configs',
+  }),
 });
diff --git a/src/routes/protos/detail.$id.tsx b/src/routes/protos/detail.$id.tsx
index 672593445..0f98d9c08 100644
--- a/src/routes/protos/detail.$id.tsx
+++ b/src/routes/protos/detail.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartProto } from 
'@/components/form-slice/FormPartProto';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_PROTOS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -119,7 +120,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('protos.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('protos.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('protos.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -153,4 +157,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/protos/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'protos',
+    to: '/protos',
+  }),
 });
diff --git a/src/routes/routes/detail.$id.tsx b/src/routes/routes/detail.$id.tsx
index 43e98bd03..4196e4e6e 100644
--- a/src/routes/routes/detail.$id.tsx
+++ b/src/routes/routes/detail.$id.tsx
@@ -48,6 +48,7 @@ import {
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_ROUTES } from '@/config/constant';
 import { req } from '@/config/req';
@@ -148,7 +149,10 @@ export const RouteDetail = (props: RouteDetailProps) => {
       <PageHeader
         title={t('info.edit.title', { name: t('routes.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('routes.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('routes.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -198,4 +202,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/routes/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'routes',
+    to: '/routes',
+  }),
 });
diff --git a/src/routes/secrets/detail.$manager.$id.tsx 
b/src/routes/secrets/detail.$manager.$id.tsx
index d5771d30f..1dd1118e7 100644
--- a/src/routes/secrets/detail.$manager.$id.tsx
+++ b/src/routes/secrets/detail.$manager.$id.tsx
@@ -35,6 +35,7 @@ import { FormPartSecret } from 
'@/components/form-slice/FormPartSecret';
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_SECRETS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -125,7 +126,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('secrets.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('secrets.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('secrets.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -155,4 +159,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/secrets/detail/$manager/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'secrets',
+    to: '/secrets',
+  }),
 });
diff --git a/src/routes/services/detail.$id/index.tsx 
b/src/routes/services/detail.$id/index.tsx
index b6f879f90..99fbf6b31 100644
--- a/src/routes/services/detail.$id/index.tsx
+++ b/src/routes/services/detail.$id/index.tsx
@@ -39,6 +39,7 @@ import {
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_SERVICES } from '@/config/constant';
 import { req } from '@/config/req';
@@ -131,7 +132,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('services.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('services.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('services.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -169,4 +173,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/services/detail/$id/')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'services',
+    to: '/services',
+  }),
 });
diff --git a/src/routes/services/detail.$id/routes/detail.$routeId.tsx 
b/src/routes/services/detail.$id/routes/detail.$routeId.tsx
index bec09ac9f..9e0ffcf7f 100644
--- a/src/routes/services/detail.$id/routes/detail.$routeId.tsx
+++ b/src/routes/services/detail.$id/routes/detail.$routeId.tsx
@@ -20,6 +20,7 @@ import {
   useParams,
 } from '@tanstack/react-router';
 
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import { RouteDetail } from '@/routes/routes/detail.$id';
 import { CommonFormContext } from '@/utils/form-context';
 
@@ -47,4 +48,9 @@ export const Route = createFileRoute(
   '/services/detail/$id/routes/detail/$routeId'
 )({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'routeId',
+    resource: 'routes',
+    to: '/services/detail/$id/routes',
+  }),
 });
diff --git a/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx 
b/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx
index 7809db51c..5527f256c 100644
--- a/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx
+++ b/src/routes/services/detail.$id/stream_routes/detail.$routeId.tsx
@@ -20,6 +20,7 @@ import {
   useParams,
 } from '@tanstack/react-router';
 
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import { StreamRoutesErrorComponent } from 
'@/components/page-slice/stream_routes/ErrorComponent';
 import { StreamRouteDetail } from '@/routes/stream_routes/detail.$id';
 import { CommonFormContext } from '@/utils/form-context';
@@ -48,5 +49,11 @@ export const Route = createFileRoute(
   '/services/detail/$id/stream_routes/detail/$routeId'
 )({
   component: RouteComponent,
-  errorComponent: StreamRoutesErrorComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'routeId',
+    resource: 'streamRoutes',
+    to: '/services/detail/$id/stream_routes',
+    // 400 = stream mode disabled on the gateway; that hint must survive.
+    fallback: StreamRoutesErrorComponent,
+  }),
 });
diff --git a/src/routes/ssls/detail.$id.tsx b/src/routes/ssls/detail.$id.tsx
index c7a1b7e98..2f91a170d 100644
--- a/src/routes/ssls/detail.$id.tsx
+++ b/src/routes/ssls/detail.$id.tsx
@@ -40,6 +40,7 @@ import {
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_SSLS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -136,7 +137,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('ssls.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('ssls.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('ssls.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -164,4 +168,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/ssls/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'ssls',
+    to: '/ssls',
+  }),
 });
diff --git a/src/routes/stream_routes/detail.$id.tsx 
b/src/routes/stream_routes/detail.$id.tsx
index 16d2e5f4a..ce46e0a2d 100644
--- a/src/routes/stream_routes/detail.$id.tsx
+++ b/src/routes/stream_routes/detail.$id.tsx
@@ -37,6 +37,7 @@ import { produceToNestedUpstreamForm } from 
'@/components/form-slice/FormPartUps
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { StreamRoutesErrorComponent } from 
'@/components/page-slice/stream_routes/ErrorComponent';
 import { API_STREAM_ROUTES } from '@/config/constant';
@@ -128,7 +129,10 @@ export const StreamRouteDetail = (props: 
StreamRouteDetailProps) => {
       <PageHeader
         title={t('info.edit.title', { name: t('streamRoutes.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('streamRoutes.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('streamRoutes.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -181,5 +185,11 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/stream_routes/detail/$id')({
   component: RouteComponent,
-  errorComponent: StreamRoutesErrorComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'streamRoutes',
+    to: '/stream_routes',
+    // 400 = stream mode disabled on the gateway; that hint must survive.
+    fallback: StreamRoutesErrorComponent,
+  }),
 });
diff --git a/src/routes/upstreams/detail.$id.tsx 
b/src/routes/upstreams/detail.$id.tsx
index f4921d249..c6382ba3f 100644
--- a/src/routes/upstreams/detail.$id.tsx
+++ b/src/routes/upstreams/detail.$id.tsx
@@ -44,6 +44,7 @@ import {
 import { FormTOCBox } from '@/components/form-slice/FormSection';
 import { FormSectionGeneral } from 
'@/components/form-slice/FormSectionGeneral';
 import { DeleteResourceBtn } from '@/components/page/DeleteResourceBtn';
+import { genDetailErrorComponent } from '@/components/page/DetailNotFound';
 import PageHeader from '@/components/page/PageHeader';
 import { API_UPSTREAMS } from '@/config/constant';
 import { req } from '@/config/req';
@@ -150,7 +151,10 @@ function RouteComponent() {
       <PageHeader
         title={t('info.edit.title', { name: t('upstreams.singular') })}
         {...(readOnly && {
-          title: t('info.detail.title', { name: t('upstreams.singular') }),
+          title: t('info.detail.titleWithId', {
+            name: t('upstreams.singular'),
+            id,
+          }),
           extra: (
             <Group>
               <Button
@@ -184,4 +188,9 @@ function RouteComponent() {
 
 export const Route = createFileRoute('/upstreams/detail/$id')({
   component: RouteComponent,
+  errorComponent: genDetailErrorComponent({
+    idParam: 'id',
+    resource: 'upstreams',
+    to: '/upstreams',
+  }),
 });
diff --git a/src/utils/error.test.ts b/src/utils/error.test.ts
new file mode 100644
index 000000000..a48630849
--- /dev/null
+++ b/src/utils/error.test.ts
@@ -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 { AxiosError, type AxiosResponse } from 'axios';
+import { describe, expect, it } from 'vitest';
+
+import { isNotFoundError } from './error';
+
+// Only `status` is read, so a cast keeps the fixtures readable rather than
+// constructing a full AxiosResponse with headers and config.
+const withStatus = (status: number) => {
+  const err = new AxiosError('request failed');
+  err.response = { status } as AxiosResponse;
+  return err;
+};
+
+describe('isNotFoundError', () => {
+  it('accepts an axios error carrying 404', () => {
+    expect(isNotFoundError(withStatus(404))).toBe(true);
+  });
+
+  it.each([401, 400, 500, 503])('rejects an axios error carrying %i', (status) 
=> {
+    expect(isNotFoundError(withStatus(status))).toBe(false);
+  });
+
+  it('rejects an axios error with no response at all', () => {
+    // network-level failure: unreachable backend, timeout, CORS preflight.
+    // There is no status to read, so this is not "does not exist".
+    expect(isNotFoundError(new AxiosError('Network Error'))).toBe(false);
+  });
+
+  it.each([new Error('boom'), null, undefined, 'Key not found', { status: 404 
}])(
+    'rejects a non-axios value (%s)',
+    (value) => {
+      expect(isNotFoundError(value)).toBe(false);
+    }
+  );
+});
diff --git a/src/config/global.ts b/src/utils/error.ts
similarity index 52%
copy from src/config/global.ts
copy to src/utils/error.ts
index c1dfad63b..5c020f74f 100644
--- a/src/config/global.ts
+++ b/src/utils/error.ts
@@ -14,32 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { QueryClient } from '@tanstack/react-query';
-import { createRouter } from '@tanstack/react-router';
 import { HttpStatusCode, isAxiosError } from 'axios';
 
-import { routeTree } from '@/routeTree.gen';
-
-import { BASE_PATH } from './constant';
-
-export const router = createRouter({ routeTree, basepath: BASE_PATH });
-
-export type Router = typeof router;
-
-export const queryClient = new QueryClient({
-  defaultOptions: {
-    queries: {
-      retry: (failureCount, error) => {
-        // retrying a 401 cannot succeed until the user fixes the admin
-        // key; fail fast so the settings modal appears immediately
-        if (
-          isAxiosError(error) &&
-          error.response?.status === HttpStatusCode.Unauthorized
-        ) {
-          return false;
-        }
-        return failureCount < 3;
-      },
-    },
-  },
-});
+/**
+ * A 404 from the Admin API means the resource does not exist.
+ *
+ * Three places must agree on this judgement: the query client (a 404 must
+ * not be retried), the response interceptor (a 404 on a read must not
+ * toast), and the detail pages' error component (a 404 renders the
+ * not-found panel). An axios error with no `response` is a network-level
+ * failure, not an absence, and is deliberately excluded.
+ */
+export const isNotFoundError = (error: unknown): boolean =>
+  isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound;

Reply via email to