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,

Reply via email to