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;