From 2b07ab672af10cb1bd3908e9a62a8fa61759cf95 Mon Sep 17 00:00:00 2001 From: Li Fengmin <2080291162@qq.com> Date: Sat, 5 Sep 2026 18:42:51 +0800 Subject: [PATCH 1/3] fix: surface failed component list loads in expose components form A failed /tools, /resources, or /prompts request rendered identically to a successful empty one: the section showed a zero count with no error. Read the useQuery error per section, show the count line as "Failed to load ..." with an error notification and a retry action, and keep real counts (including legitimate zeros) for sections whose request succeeded. Signed-off-by: Li Fengmin <2080291162@qq.com> --- .../gateways/ExposeComponentsForm.test.tsx | 49 +++++++++- .../gateways/ExposeComponentsForm.tsx | 96 +++++++++++++++++-- 2 files changed, 138 insertions(+), 7 deletions(-) diff --git a/src/components/gateways/ExposeComponentsForm.test.tsx b/src/components/gateways/ExposeComponentsForm.test.tsx index 5027c7ba..a0174afc 100644 --- a/src/components/gateways/ExposeComponentsForm.test.tsx +++ b/src/components/gateways/ExposeComponentsForm.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, beforeAll, afterAll, afterEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; @@ -593,4 +593,51 @@ describe("ExposeComponentsForm", () => { }); }); }); + + describe("Failed list loads", () => { + it("should distinguish a failed load from a successful empty list", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Failed to load tools"); + expect(screen.queryByText("0 tools")).not.toBeInTheDocument(); + // Sections whose own request succeeded keep showing their real counts. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + expect(screen.getByText("3 prompt templates")).toBeInTheDocument(); + }); + + it("should restore the count and clear the error after a successful retry", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + const user = userEvent.setup(); + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + server.resetHandlers(); + await user.click(within(alert).getByRole("button", { name: "Retry" })); + + await waitFor(() => { + expect(screen.getByText("3 tools")).toBeInTheDocument(); + }); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("should show a failed state for each section that failed to load", async () => { + server.use( + http.get("/api/tools", () => new HttpResponse(null, { status: 500 })), + http.get("/api/prompts", () => new HttpResponse(null, { status: 500 })), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Failed to load tools")).toBeInTheDocument(); + }); + expect(screen.getByText("Failed to load prompt templates")).toBeInTheDocument(); + // The section whose request succeeded keeps showing its real count. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/gateways/ExposeComponentsForm.tsx b/src/components/gateways/ExposeComponentsForm.tsx index b6a1937a..751790e0 100644 --- a/src/components/gateways/ExposeComponentsForm.tsx +++ b/src/components/gateways/ExposeComponentsForm.tsx @@ -24,6 +24,7 @@ import { useQuery } from "@/hooks/useQuery"; import { Loading } from "@/components/ui/loading"; import { createVirtualServer } from "@/api/virtualServers"; import { InlineNotification } from "@/components/ui/inline-notification"; +import { STATUS_TONE_CLASS } from "@/lib/status"; import { useRouter } from "@/router"; import type { CreateServerDetails } from "@/components/gateways/types"; import type { Visibility } from "@/types/server"; @@ -159,16 +160,19 @@ export function ExposeComponentsForm({ // Fetch tools, resources, and prompts for this gateway const { data: toolsData, + error: toolsError, isLoading: toolsLoading, refetch: refetchTools, } = useQuery(`/tools?limit=1000&gateway_id=${gatewayId}`); const { data: resourcesData, + error: resourcesError, isLoading: resourcesLoading, refetch: refetchResources, } = useQuery(`/resources?limit=1000&gateway_id=${gatewayId}`); const { data: promptsData, + error: promptsError, isLoading: promptsLoading, refetch: refetchPrompts, } = useQuery(`/prompts?limit=1000&gateway_id=${gatewayId}`); @@ -352,8 +356,14 @@ export function ExposeComponentsForm({ aria-hidden="true" /> - - {toolCount} {toolCount === 1 ? "tool" : "tools"} + + {toolsError + ? "Failed to load tools" + : `${toolCount} ${toolCount === 1 ? "tool" : "tools"}`} {expandedSection === "tools" ? ( @@ -369,6 +379,24 @@ export function ExposeComponentsForm({ )} + {toolsError && ( +
+ + refetchTools().catch((err) => console.error("Failed to refetch tools:", err)), + }} + /> +
+ )} + {expandedSection === "tools" && tools.length > 0 && (
- - {resourceCount} {resourceCount === 1 ? "resource" : "resources"} + + {resourcesError + ? "Failed to load resources" + : `${resourceCount} ${resourceCount === 1 ? "resource" : "resources"}`} {expandedSection === "resources" ? ( @@ -416,6 +452,26 @@ export function ExposeComponentsForm({ /> )} + + {resourcesError && ( +
+ + refetchResources().catch((err) => + console.error("Failed to refetch resources:", err), + ), + }} + /> +
+ )} {expandedSection === "resources" && resources.length > 0 && (
- - {promptCount} prompt {promptCount === 1 ? "template" : "templates"} + + {promptsError + ? "Failed to load prompt templates" + : `${promptCount} prompt ${promptCount === 1 ? "template" : "templates"}`} {expandedSection === "prompts" ? ( @@ -463,6 +527,26 @@ export function ExposeComponentsForm({ /> )} + + {promptsError && ( +
+ + refetchPrompts().catch((err) => + console.error("Failed to refetch prompts:", err), + ), + }} + /> +
+ )} {expandedSection === "prompts" && prompts.length > 0 && (
Date: Mon, 7 Sep 2026 19:29:01 +0800 Subject: [PATCH 2/3] fix: i18n expose-components load errors and per-section loading guards - Add gateways.exposeComponents.error.{tools,resources,prompts}(WithDetail) and exposeComponents.promptCount keys to en-US, pt-BR, and es-ES; reuse the existing gateways.card.*Count keys for tools and resources. - Replace the form-level spinner on refetch with per-section count-row states (loading / error / count); the full-form spinner now only covers the initial load before any section has resolved, so retrying one failed section no longer unmounts the healthy ones. Signed-off-by: Li Fengmin <2080291162@qq.com> --- .../gateways/ExposeComponentsForm.test.tsx | 32 +++++++++ .../gateways/ExposeComponentsForm.tsx | 65 ++++++++++++++----- src/i18n/locales/en-US/gateways.json | 7 ++ src/i18n/locales/es-ES/gateways.json | 7 ++ src/i18n/locales/pt-BR/gateways.json | 7 ++ 5 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/components/gateways/ExposeComponentsForm.test.tsx b/src/components/gateways/ExposeComponentsForm.test.tsx index a0174afc..6ff5513c 100644 --- a/src/components/gateways/ExposeComponentsForm.test.tsx +++ b/src/components/gateways/ExposeComponentsForm.test.tsx @@ -624,6 +624,38 @@ describe("ExposeComponentsForm", () => { expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + it("should keep healthy sections mounted while a failed section retries", async () => { + server.use(http.get("/api/tools", () => new HttpResponse(null, { status: 500 }))); + + const user = userEvent.setup(); + renderWithProviders(); + + const alert = await screen.findByRole("alert"); + expect(screen.getByText("2 resources")).toBeInTheDocument(); + + // Make the retry hang so the per-section loading state stays observable. + server.use( + http.get("/api/tools", async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return HttpResponse.json(mockTools); + }), + ); + await user.click(within(alert).getByRole("button", { name: "Retry" })); + + // The tools section shows its own loading row instead of the error... + await waitFor(() => { + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + // ...and the sections that already loaded are not replaced by a form-level spinner. + expect(screen.getByText("2 resources")).toBeInTheDocument(); + expect(screen.getByText("3 prompt templates")).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: /loading/i })).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("3 tools")).toBeInTheDocument(); + }); + }); + it("should show a failed state for each section that failed to load", async () => { server.use( http.get("/api/tools", () => new HttpResponse(null, { status: 500 })), diff --git a/src/components/gateways/ExposeComponentsForm.tsx b/src/components/gateways/ExposeComponentsForm.tsx index 751790e0..fc7e81d0 100644 --- a/src/components/gateways/ExposeComponentsForm.tsx +++ b/src/components/gateways/ExposeComponentsForm.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from "react"; +import { useIntl } from "react-intl"; import { ChevronDown, ChevronRight, @@ -146,6 +147,7 @@ export function ExposeComponentsForm({ clearFetchToolsNotification, }: ExposeComponentsFormProps) { const { navigate } = useRouter(); + const intl = useIntl(); const [expandedSection, setExpandedSection] = useState("tools"); const [selectedTools, setSelectedTools] = useState>(new Set()); const [selectedResources, setSelectedResources] = useState>(new Set()); @@ -198,6 +200,16 @@ export function ExposeComponentsForm({ const promptCount = prompts.length; const isLoading = toolsLoading || resourcesLoading || promptsLoading; + // Only the very first load (before any section has resolved) replaces the whole + // form with a spinner; a per-section retry must keep the healthy sections visible. + const isInitialLoad = + isLoading && + toolsData === undefined && + resourcesData === undefined && + promptsData === undefined && + !toolsError && + !resourcesError && + !promptsError; const toggleSection = useCallback((section: string) => { setExpandedSection((prev) => (prev === section ? null : section)); @@ -279,7 +291,7 @@ export function ExposeComponentsForm({ } }; - if (isLoading) { + if (isInitialLoad) { return (
@@ -361,9 +373,11 @@ export function ExposeComponentsForm({ toolsError ? STATUS_TONE_CLASS.error : "text-neutral-600 dark:text-neutral-400" }`} > - {toolsError - ? "Failed to load tools" - : `${toolCount} ${toolCount === 1 ? "tool" : "tools"}`} + {toolsLoading + ? intl.formatMessage({ id: "common.loading" }) + : toolsError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.tools" }) + : intl.formatMessage({ id: "gateways.card.toolCount" }, { count: toolCount })}
{expandedSection === "tools" ? ( @@ -385,8 +399,11 @@ export function ExposeComponentsForm({ type="error" message={ toolsError.message - ? `Failed to load tools: ${toolsError.message}` - : "Failed to load tools" + ? intl.formatMessage( + { id: "gateways.exposeComponents.error.toolsWithDetail" }, + { detail: toolsError.message }, + ) + : intl.formatMessage({ id: "gateways.exposeComponents.error.tools" }) } action={{ label: "Retry", @@ -435,9 +452,14 @@ export function ExposeComponentsForm({ : "text-neutral-600 dark:text-neutral-400" }`} > - {resourcesError - ? "Failed to load resources" - : `${resourceCount} ${resourceCount === 1 ? "resource" : "resources"}`} + {resourcesLoading + ? intl.formatMessage({ id: "common.loading" }) + : resourcesError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.resources" }) + : intl.formatMessage( + { id: "gateways.card.resourceCount" }, + { count: resourceCount }, + )}
{expandedSection === "resources" ? ( @@ -459,8 +481,11 @@ export function ExposeComponentsForm({ type="error" message={ resourcesError.message - ? `Failed to load resources: ${resourcesError.message}` - : "Failed to load resources" + ? intl.formatMessage( + { id: "gateways.exposeComponents.error.resourcesWithDetail" }, + { detail: resourcesError.message }, + ) + : intl.formatMessage({ id: "gateways.exposeComponents.error.resources" }) } action={{ label: "Retry", @@ -510,9 +535,14 @@ export function ExposeComponentsForm({ : "text-neutral-600 dark:text-neutral-400" }`} > - {promptsError - ? "Failed to load prompt templates" - : `${promptCount} prompt ${promptCount === 1 ? "template" : "templates"}`} + {promptsLoading + ? intl.formatMessage({ id: "common.loading" }) + : promptsError + ? intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" }) + : intl.formatMessage( + { id: "gateways.exposeComponents.promptCount" }, + { count: promptCount }, + )}
{expandedSection === "prompts" ? ( @@ -534,8 +564,11 @@ export function ExposeComponentsForm({ type="error" message={ promptsError.message - ? `Failed to load prompt templates: ${promptsError.message}` - : "Failed to load prompt templates" + ? intl.formatMessage( + { id: "gateways.exposeComponents.error.promptsWithDetail" }, + { detail: promptsError.message }, + ) + : intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" }) } action={{ label: "Retry", diff --git a/src/i18n/locales/en-US/gateways.json b/src/i18n/locales/en-US/gateways.json index a4d4cefa..39c03efa 100644 --- a/src/i18n/locales/en-US/gateways.json +++ b/src/i18n/locales/en-US/gateways.json @@ -83,6 +83,13 @@ "gateways.card.resourceCount": "{count, plural, one {# resource} other {# resources}}", "gateways.card.promptCount": "{count, plural, one {# prompt} other {# prompts}}", "gateways.card.notSyncedYet": "Not synced yet", + "gateways.exposeComponents.error.tools": "Failed to load tools", + "gateways.exposeComponents.error.toolsWithDetail": "Failed to load tools: {detail}", + "gateways.exposeComponents.error.resources": "Failed to load resources", + "gateways.exposeComponents.error.resourcesWithDetail": "Failed to load resources: {detail}", + "gateways.exposeComponents.error.prompts": "Failed to load prompt templates", + "gateways.exposeComponents.error.promptsWithDetail": "Failed to load prompt templates: {detail}", + "gateways.exposeComponents.promptCount": "{count, plural, one {# prompt template} other {# prompt templates}}", "gateways.delete.title": "Delete virtual server", "gateways.delete.description": "Are you sure you want to delete {name}? This action cannot be undone.", "gateways.delete.deleting": "Deleting...", diff --git a/src/i18n/locales/es-ES/gateways.json b/src/i18n/locales/es-ES/gateways.json index 8184804f..2afc38f7 100644 --- a/src/i18n/locales/es-ES/gateways.json +++ b/src/i18n/locales/es-ES/gateways.json @@ -83,6 +83,13 @@ "gateways.card.resourceCount": "{count, plural, one {# recurso} other {# recursos}}", "gateways.card.promptCount": "{count, plural, one {# prompt} other {# prompts}}", "gateways.card.notSyncedYet": "Aún no sincronizado", + "gateways.exposeComponents.error.tools": "Error al cargar herramientas", + "gateways.exposeComponents.error.toolsWithDetail": "Error al cargar herramientas: {detail}", + "gateways.exposeComponents.error.resources": "Error al cargar recursos", + "gateways.exposeComponents.error.resourcesWithDetail": "Error al cargar recursos: {detail}", + "gateways.exposeComponents.error.prompts": "Error al cargar plantillas de prompt", + "gateways.exposeComponents.error.promptsWithDetail": "Error al cargar plantillas de prompt: {detail}", + "gateways.exposeComponents.promptCount": "{count, plural, one {# plantilla de prompt} other {# plantillas de prompt}}", "gateways.delete.title": "Eliminar servidor virtual", "gateways.delete.description": "¿Seguro que desea eliminar {name}? Esta acción no se puede deshacer.", "gateways.delete.deleting": "Eliminando...", diff --git a/src/i18n/locales/pt-BR/gateways.json b/src/i18n/locales/pt-BR/gateways.json index 4d2ef541..1813670b 100644 --- a/src/i18n/locales/pt-BR/gateways.json +++ b/src/i18n/locales/pt-BR/gateways.json @@ -83,6 +83,13 @@ "gateways.card.resourceCount": "{count, plural, one {# recurso} other {# recursos}}", "gateways.card.promptCount": "{count, plural, one {# prompt} other {# prompts}}", "gateways.card.notSyncedYet": "Ainda não sincronizado", + "gateways.exposeComponents.error.tools": "Falha ao carregar ferramentas", + "gateways.exposeComponents.error.toolsWithDetail": "Falha ao carregar ferramentas: {detail}", + "gateways.exposeComponents.error.resources": "Falha ao carregar recursos", + "gateways.exposeComponents.error.resourcesWithDetail": "Falha ao carregar recursos: {detail}", + "gateways.exposeComponents.error.prompts": "Falha ao carregar modelos de prompt", + "gateways.exposeComponents.error.promptsWithDetail": "Falha ao carregar modelos de prompt: {detail}", + "gateways.exposeComponents.promptCount": "{count, plural, one {# modelo de prompt} other {# modelos de prompt}}", "gateways.delete.title": "Excluir servidor virtual", "gateways.delete.description": "Tem certeza de que deseja excluir {name}? Esta ação não pode ser desfeita.", "gateways.delete.deleting": "Excluindo...", From d45fdbf683cae9cfe8bb53dc3390139f79112e75 Mon Sep 17 00:00:00 2001 From: Li Fengmin <2080291162@qq.com> Date: Mon, 7 Sep 2026 21:29:03 +0800 Subject: [PATCH 3/3] i18n: localize Retry button label in ExposeComponentsForm Signed-off-by: Li Fengmin <2080291162@qq.com> --- src/components/gateways/ExposeComponentsForm.tsx | 6 +++--- src/i18n/locales/en-US/common.json | 1 + src/i18n/locales/es-ES/common.json | 1 + src/i18n/locales/pt-BR/common.json | 1 + 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/gateways/ExposeComponentsForm.tsx b/src/components/gateways/ExposeComponentsForm.tsx index fc7e81d0..ec2af0ef 100644 --- a/src/components/gateways/ExposeComponentsForm.tsx +++ b/src/components/gateways/ExposeComponentsForm.tsx @@ -406,7 +406,7 @@ export function ExposeComponentsForm({ : intl.formatMessage({ id: "gateways.exposeComponents.error.tools" }) } action={{ - label: "Retry", + label: intl.formatMessage({ id: "common.button.retry" }), onClick: () => refetchTools().catch((err) => console.error("Failed to refetch tools:", err)), }} @@ -488,7 +488,7 @@ export function ExposeComponentsForm({ : intl.formatMessage({ id: "gateways.exposeComponents.error.resources" }) } action={{ - label: "Retry", + label: intl.formatMessage({ id: "common.button.retry" }), onClick: () => refetchResources().catch((err) => console.error("Failed to refetch resources:", err), @@ -571,7 +571,7 @@ export function ExposeComponentsForm({ : intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" }) } action={{ - label: "Retry", + label: intl.formatMessage({ id: "common.button.retry" }), onClick: () => refetchPrompts().catch((err) => console.error("Failed to refetch prompts:", err), diff --git a/src/i18n/locales/en-US/common.json b/src/i18n/locales/en-US/common.json index e8199626..2f46cc7c 100644 --- a/src/i18n/locales/en-US/common.json +++ b/src/i18n/locales/en-US/common.json @@ -9,6 +9,7 @@ "common.button.close": "Close", "common.button.confirm": "Confirm", "common.button.back": "Back", + "common.button.retry": "Retry", "common.button.submitting": "Submitting...", "common.copyValue": "Copy {label}", "common.copyCode": "Copy code", diff --git a/src/i18n/locales/es-ES/common.json b/src/i18n/locales/es-ES/common.json index 181da399..daa19ec7 100644 --- a/src/i18n/locales/es-ES/common.json +++ b/src/i18n/locales/es-ES/common.json @@ -9,6 +9,7 @@ "common.button.close": "Cerrar", "common.button.confirm": "Confirmar", "common.button.back": "Volver", + "common.button.retry": "Reintentar", "common.button.submitting": "Enviando...", "common.copyValue": "Copiar {label}", "common.copyCode": "Copiar código", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f4733ff9..ecf301dc 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -9,6 +9,7 @@ "common.button.close": "Fechar", "common.button.confirm": "Confirmar", "common.button.back": "Voltar", + "common.button.retry": "Tentar novamente", "common.button.submitting": "Enviando...", "common.copyValue": "Copiar {label}", "common.copyCode": "Copiar código",