Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion src/components/gateways/ExposeComponentsForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -593,4 +593,83 @@ 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(<ExposeComponentsForm {...defaultProps} />);

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(<ExposeComponentsForm {...defaultProps} />);

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 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(<ExposeComponentsForm {...defaultProps} />);

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 })),
http.get("/api/prompts", () => new HttpResponse(null, { status: 500 })),
);

renderWithProviders(<ExposeComponentsForm {...defaultProps} />);

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();
});
});
});
131 changes: 124 additions & 7 deletions src/components/gateways/ExposeComponentsForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback, useEffect } from "react";
import { useIntl } from "react-intl";
import {
ChevronDown,
ChevronRight,
Expand All @@ -24,6 +25,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";
Expand Down Expand Up @@ -145,6 +147,7 @@ export function ExposeComponentsForm({
clearFetchToolsNotification,
}: ExposeComponentsFormProps) {
const { navigate } = useRouter();
const intl = useIntl();
const [expandedSection, setExpandedSection] = useState<string | null>("tools");
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [selectedResources, setSelectedResources] = useState<Set<string>>(new Set());
Expand All @@ -159,16 +162,19 @@ export function ExposeComponentsForm({
// Fetch tools, resources, and prompts for this gateway
const {
data: toolsData,
error: toolsError,
isLoading: toolsLoading,
refetch: refetchTools,
} = useQuery<ToolsResponse>(`/tools?limit=1000&gateway_id=${gatewayId}`);
const {
data: resourcesData,
error: resourcesError,
isLoading: resourcesLoading,
refetch: refetchResources,
} = useQuery<ResourcesResponse>(`/resources?limit=1000&gateway_id=${gatewayId}`);
const {
data: promptsData,
error: promptsError,
isLoading: promptsLoading,
refetch: refetchPrompts,
} = useQuery<PromptsResponse>(`/prompts?limit=1000&gateway_id=${gatewayId}`);
Expand All @@ -194,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));
Expand Down Expand Up @@ -275,7 +291,7 @@ export function ExposeComponentsForm({
}
};

if (isLoading) {
if (isInitialLoad) {
return (
<div className="mx-auto mt-6 w-full max-w-5xl rounded-xl border border-neutral-200 bg-inherit p-0 shadow-[0_12px_40px_rgba(15,23,42,0.12)] dark:border-neutral-800">
<div className="flex items-center justify-center p-12">
Expand Down Expand Up @@ -352,8 +368,16 @@ export function ExposeComponentsForm({
aria-hidden="true"
/>
</div>
<span className="text-base font-normal text-neutral-600 dark:text-neutral-400">
{toolCount} {toolCount === 1 ? "tool" : "tools"}
<span
className={`text-base font-normal ${
toolsError ? STATUS_TONE_CLASS.error : "text-neutral-600 dark:text-neutral-400"
}`}
>
{toolsLoading
? intl.formatMessage({ id: "common.loading" })
: toolsError
? intl.formatMessage({ id: "gateways.exposeComponents.error.tools" })
: intl.formatMessage({ id: "gateways.card.toolCount" }, { count: toolCount })}
</span>
</div>
{expandedSection === "tools" ? (
Expand All @@ -369,6 +393,27 @@ export function ExposeComponentsForm({
)}
</Button>

{toolsError && (
<div className="px-6 pb-4">
<InlineNotification
type="error"
message={
toolsError.message
? intl.formatMessage(
{ id: "gateways.exposeComponents.error.toolsWithDetail" },
{ detail: toolsError.message },
)
: intl.formatMessage({ id: "gateways.exposeComponents.error.tools" })
}
action={{
label: intl.formatMessage({ id: "common.button.retry" }),
onClick: () =>
refetchTools().catch((err) => console.error("Failed to refetch tools:", err)),
}}
/>
</div>
)}

{expandedSection === "tools" && tools.length > 0 && (
<div id="tools-region" role="region" aria-label="Tools">
<MCPObjectsTable
Expand Down Expand Up @@ -400,8 +445,21 @@ export function ExposeComponentsForm({
aria-hidden="true"
/>
</div>
<span className="text-base font-normal text-neutral-600 dark:text-neutral-400">
{resourceCount} {resourceCount === 1 ? "resource" : "resources"}
<span
className={`text-base font-normal ${
resourcesError
? STATUS_TONE_CLASS.error
: "text-neutral-600 dark:text-neutral-400"
}`}
>
{resourcesLoading
? intl.formatMessage({ id: "common.loading" })
: resourcesError
? intl.formatMessage({ id: "gateways.exposeComponents.error.resources" })
: intl.formatMessage(
{ id: "gateways.card.resourceCount" },
{ count: resourceCount },
)}
</span>
</div>
{expandedSection === "resources" ? (
Expand All @@ -416,6 +474,29 @@ export function ExposeComponentsForm({
/>
)}
</Button>

{resourcesError && (
<div className="px-6 pb-4">
<InlineNotification
type="error"
message={
resourcesError.message
? intl.formatMessage(
{ id: "gateways.exposeComponents.error.resourcesWithDetail" },
{ detail: resourcesError.message },
)
: intl.formatMessage({ id: "gateways.exposeComponents.error.resources" })
}
action={{
label: intl.formatMessage({ id: "common.button.retry" }),
onClick: () =>
refetchResources().catch((err) =>
console.error("Failed to refetch resources:", err),
),
}}
/>
</div>
)}
{expandedSection === "resources" && resources.length > 0 && (
<div id="resources-region" role="region" aria-label="Resources">
<MCPObjectsTable
Expand Down Expand Up @@ -447,8 +528,21 @@ export function ExposeComponentsForm({
aria-hidden="true"
/>
</div>
<span className="text-base font-normal text-neutral-600 dark:text-neutral-400">
{promptCount} prompt {promptCount === 1 ? "template" : "templates"}
<span
className={`text-base font-normal ${
promptsError
? STATUS_TONE_CLASS.error
: "text-neutral-600 dark:text-neutral-400"
}`}
>
{promptsLoading
? intl.formatMessage({ id: "common.loading" })
: promptsError
? intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" })
: intl.formatMessage(
{ id: "gateways.exposeComponents.promptCount" },
{ count: promptCount },
)}
</span>
</div>
{expandedSection === "prompts" ? (
Expand All @@ -463,6 +557,29 @@ export function ExposeComponentsForm({
/>
)}
</Button>

{promptsError && (
<div className="px-6 pb-4">
<InlineNotification
type="error"
message={
promptsError.message
? intl.formatMessage(
{ id: "gateways.exposeComponents.error.promptsWithDetail" },
{ detail: promptsError.message },
)
: intl.formatMessage({ id: "gateways.exposeComponents.error.prompts" })
}
action={{
label: intl.formatMessage({ id: "common.button.retry" }),
onClick: () =>
refetchPrompts().catch((err) =>
console.error("Failed to refetch prompts:", err),
),
}}
/>
</div>
)}
{expandedSection === "prompts" && prompts.length > 0 && (
<div id="prompts-region" role="region" aria-label="Prompt templates">
<MCPObjectsTable
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/locales/en-US/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es-ES/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/locales/es-ES/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/locales/pt-BR/gateways.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
Loading