This is an automated email from the ASF dual-hosted git repository.
guoqqqi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new 5c0c64968 fix(plugin-metadata): stop toasting 404 probes as errors
(#3419)
5c0c64968 is described below
commit 5c0c649687d7f55833264717bd2bb659270a70ce
Author: Ming Wen <[email protected]>
AuthorDate: Mon Jul 13 10:02:47 2026 +0800
fix(plugin-metadata): stop toasting 404 probes as errors (#3419)
---
.../plugin-metadata.no-false-404-toasts.spec.ts | 118 +++++++++++++++++++++
.../page-slice/plugin_metadata/PluginMetadata.tsx | 26 +++--
src/components/page-slice/plugin_metadata/hooks.ts | 39 ++++++-
3 files changed, 173 insertions(+), 10 deletions(-)
diff --git a/e2e/tests/regression/plugin-metadata.no-false-404-toasts.spec.ts
b/e2e/tests/regression/plugin-metadata.no-false-404-toasts.spec.ts
new file mode 100644
index 000000000..b38c20690
--- /dev/null
+++ b/e2e/tests/regression/plugin-metadata.no-false-404-toasts.spec.ts
@@ -0,0 +1,118 @@
+/**
+ * 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.
+ */
+
+// Regression for apache/apisix-dashboard#3412: the Plugin Metadata page
+// probes GET /plugin_metadata/{name} once per metadata-capable plugin.
+// The Admin API answers 404 for every plugin whose metadata was never
+// configured — the normal state for most plugins on any deployment — and
+// each 404 used to fall through to the global axios interceptor as a red
+// "Key not found" toast. A fresh page load must show zero error toasts.
+
+import { pluginMetadataPom } from '@e2e/pom/plugin_metadata';
+import { e2eReq } from '@e2e/utils/req';
+import { test } from '@e2e/utils/test';
+import { expect } from '@playwright/test';
+
+import { API_PLUGIN_METADATA } from '@/config/constant';
+
+// Plugin metadata has no deleteAll helper; clear plugins commonly used in
+// other specs so the page probes an unconfigured state.
+const deleteKnownPluginMetadata = async () => {
+ const pluginsToClean = ['http-logger', 'syslog', 'skywalking', 'datadog'];
+ await Promise.all(
+ pluginsToClean.map((name) =>
+ e2eReq.delete(`${API_PLUGIN_METADATA}/${name}`).catch(() => {
+ // ignore: metadata may not exist
+ })
+ )
+ );
+};
+
+test.beforeAll(async () => {
+ await deleteKnownPluginMetadata();
+});
+
+test('page load with unconfigured plugins shows no error toasts', async ({
+ page,
+}) => {
+ // Track the per-plugin probes so the assertion runs after the storm of
+ // 404s has actually happened, not before.
+ let probes = 0;
+ page.on('response', (res) => {
+ if (res.url().includes('/apisix/admin/plugin_metadata/')) probes += 1;
+ });
+
+ await pluginMetadataPom.toIndex(page);
+ await pluginMetadataPom.isIndexPage(page);
+
+ // The page enumerates every metadata-capable plugin; wait until the
+ // probe count is non-trivial AND has stopped growing (storm settled).
+ let lastSeen = -1;
+ await expect
+ .poll(
+ () => {
+ const settled = probes > 5 && probes === lastSeen;
+ lastSeen = probes;
+ return settled;
+ },
+ { timeout: 15000, intervals: [1000] }
+ )
+ .toBe(true);
+
+ // A 404 probe result means "not configured" — it must not toast.
+ await expect(page.getByRole('alert')).toHaveCount(0);
+
+ // The page must remain functional: the select drawer offers plugins to
+ // configure even though every probe 404'd.
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const drawer = page.getByRole('dialog');
+ await expect(drawer.getByRole('button', { name: 'Add' }).first())
+ .toBeVisible();
+});
+
+test('a plugin whose metadata probe fails is not offered as an empty config',
async ({
+ page,
+}) => {
+ // Fault injection: a real Admin API cannot be made to 500 a single
+ // plugin's metadata GET deterministically. A failed (non-404) probe
+ // must NOT be presented as "unconfigured" — saving the offered empty
+ // config would overwrite the plugin's existing metadata.
+ await page.route('**/apisix/admin/plugin_metadata/http-logger', (route) =>
+ route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ error_msg: 'injected metadata failure' }),
+ })
+ );
+
+ await pluginMetadataPom.toIndex(page);
+ await pluginMetadataPom.isIndexPage(page);
+
+ // The real failure must surface to the user ...
+ await expect(
+ page.getByRole('alert').filter({ hasText: 'injected metadata failure' })
+ ).toBeVisible({ timeout: 15000 });
+
+ // ... and the failed plugin must not be offered in the select drawer.
+ await page.getByRole('button', { name: 'Select Plugins' }).click();
+ const drawer = page.getByRole('dialog');
+ await expect(drawer.getByRole('button', { name: 'Add' }).first())
+ .toBeVisible();
+ await expect(
+ drawer.getByText('http-logger', { exact: true })
+ ).toHaveCount(0);
+});
diff --git a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
index 8a923f602..1ba3651fe 100644
--- a/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
+++ b/src/components/page-slice/plugin_metadata/PluginMetadata.tsx
@@ -51,7 +51,8 @@ export const PluginMetadata = () => {
const { t } = useTranslation();
const metadataList = usePluginMetadataList();
- const { pluginInfoMap, hasConfigNames, allPluginNames } = metadataList;
+ const { pluginInfoMap, hasConfigNames, failedPluginNames, allPluginNames } =
+ metadataList;
const putMetadata = useMutation({
mutationFn: putPluginMetadataReq,
@@ -86,17 +87,28 @@ export const PluginMetadata = () => {
const [search, setSearch] = useState('');
const unSelected = useMemo(
- () => difference(allPluginNames ?? [], hasConfigNames),
- [allPluginNames, hasConfigNames]
+ // a plugin whose metadata fetch failed (non-404) must not be offered
+ // as an empty config: saving it would overwrite existing metadata
+ () =>
+ difference(allPluginNames ?? [], [
+ ...hasConfigNames,
+ ...failedPluginNames,
+ ]),
+ [allPluginNames, hasConfigNames, failedPluginNames]
);
const curPlugin = useMemo<PluginConfig>(() => {
if (!curPluginName) return {} as PluginConfig;
const info = pluginInfoMap.get(curPluginName);
- return info
- ? ({ name: info.name, config: info.config } as PluginConfig)
- : ({ name: curPluginName, config: {} } as PluginConfig);
- }, [curPluginName, pluginInfoMap]);
+ if (info) {
+ return { name: info.name, config: info.config } as PluginConfig;
+ }
+ // fall back to an empty config only for plugins legitimately offered
+ // as unconfigured — never for ones whose fetch failed
+ return unSelected.includes(curPluginName)
+ ? ({ name: curPluginName, config: {} } as PluginConfig)
+ : ({} as PluginConfig);
+ }, [curPluginName, pluginInfoMap, unSelected]);
const curPluginSchema = useMemo(() => {
if (!curPluginName) return {};
diff --git a/src/components/page-slice/plugin_metadata/hooks.ts
b/src/components/page-slice/plugin_metadata/hooks.ts
index 1f5c5615d..0c9da538d 100644
--- a/src/components/page-slice/plugin_metadata/hooks.ts
+++ b/src/components/page-slice/plugin_metadata/hooks.ts
@@ -16,6 +16,7 @@
*/
import { useListState, useMap } from '@mantine/hooks';
import { useQueries, useSuspenseQuery } from '@tanstack/react-query';
+import { HttpStatusCode, isAxiosError } from 'axios';
import { useDeepCompareEffect } from 'react-use';
import {
@@ -39,10 +40,20 @@ export const usePluginMetadataList = () => {
queries: names
? names.map((pluginName) => ({
...getPluginMetadataQueryOptions(pluginName, {
- // skip show 500 error toast
- [SKIP_INTERCEPTOR_HEADER]: ['500'],
+ // the Admin API answers 404 for a plugin whose metadata was
+ // never configured — that is the normal state for most
+ // plugins, not an error, so keep the interceptor quiet.
+ // Real failures (5xx, network) still toast.
+ [SKIP_INTERCEPTOR_HEADER]: ['404'],
}),
- retry: false,
+ // 404 = "not configured", retrying is pointless (and would
+ // multiply the probe storm); give real failures two more tries
+ retry: (failureCount: number, error: unknown) =>
+ failureCount < 2 &&
+ !(
+ isAxiosError(error) &&
+ error.response?.status === HttpStatusCode.NotFound
+ ),
}))
: [],
});
@@ -58,6 +69,14 @@ export const usePluginMetadataList = () => {
hasConfigNamesOp.setState([]);
for (const [index, pluginName] of names.entries()) {
const req = metadataQueries[index];
+ // 404 means "not configured yet" — offer an empty config.
+ // Any other failure (5xx, network) must NOT be presented as an
+ // empty config: saving that would overwrite existing metadata.
+ const isUnconfigured =
+ req.isError &&
+ isAxiosError(req.error) &&
+ req.error.response?.status === HttpStatusCode.NotFound;
+ if (!req.isSuccess && !isUnconfigured) continue;
const info = {
name: pluginName,
config: req.isSuccess ? req.data?.value : {},
@@ -70,11 +89,25 @@ export const usePluginMetadataList = () => {
}
}, [metadataQueries, names]);
+ // plugins whose metadata fetch failed with something other than 404;
+ // they must not be offered as an empty editable config anywhere
+ const failedPluginNames = (names ?? []).filter((_, index) => {
+ const query = metadataQueries[index];
+ return (
+ query.isError &&
+ !(
+ isAxiosError(query.error) &&
+ query.error.response?.status === HttpStatusCode.NotFound
+ )
+ );
+ });
+
return {
isLoading,
isError: pluginsListQuery.isError,
error: pluginsListQuery.error,
hasConfigNames,
+ failedPluginNames,
pluginInfoMap,
allPluginNames: names,
originalMetadataQueries: metadataQueries,