diff --git a/e2e/virtual-servers.spec.ts b/e2e/virtual-servers.spec.ts index e794b03d..5a220651 100644 --- a/e2e/virtual-servers.spec.ts +++ b/e2e/virtual-servers.spec.ts @@ -1059,6 +1059,10 @@ test.describe("Virtual Servers page", () => { await expect(detailsPanel.getByText("Visibility")).toBeVisible(); await expect(detailsPanel.getByText("Internal")).toBeVisible(); await expect(detailsPanel.getByText("development")).toBeVisible(); + + // "Try it" is the default tab; switch to Components before asserting on the + // component list. + await detailsPanel.getByRole("tab", { name: "Components" }).click(); await expect(detailsPanel.getByText("Get Repo Issues")).toBeVisible(); await expect(detailsPanel.getByText("GITHUB_GET_REPO_ISSUES")).toBeVisible(); await expect(detailsPanel.getByText("github://repo/{owner}/{repo}").first()).toBeVisible(); @@ -1083,6 +1087,62 @@ test.describe("Virtual Servers page", () => { await expect(detailsPanel.getByText("github://repo/{owner}/{repo}")).toHaveCount(0); }); + test("shows a tooltip with the full endpoint when it's truncated in the Try it tab", async ({ + page, + }) => { + await page.route("**/servers?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ servers: [MOCK_VIRTUAL_SERVER] }), + }); + }); + await page.route(`**/servers/${MOCK_VIRTUAL_SERVER.id}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_VIRTUAL_SERVER_DETAILS), + }); + }); + + // Narrow enough that the full endpoint URL can't fit on one line, forcing + // TruncatedText's CSS ellipsis to actually clip it. + await page.setViewportSize({ width: 480, height: 800 }); + + await page.goto(APP.GATEWAYS); + await page.waitForLoadState("networkidle"); + + await page.getByRole("button", { name: "Actions for testVS" }).click(); + await page.getByRole("menuitem", { name: "View details" }).click(); + + const detailsPanel = page.getByRole("region", { name: "testVS details" }); + await expect(detailsPanel).toBeVisible(); + + // "Try it" is the default tab, so the endpoint is visible without switching tabs. + // Scope to the tabpanel — the sidebar's own "URL" field renders the same + // endpoint value (via a different component), which would otherwise be an + // ambiguous second match. + const endpoint = detailsPanel + .getByRole("tabpanel") + .getByText(new RegExp(`/servers/${MOCK_VIRTUAL_SERVER.id}/mcp$`)); + await expect(endpoint).toBeVisible(); + const fullEndpointText = (await endpoint.textContent())?.trim(); + expect(fullEndpointText).toBeTruthy(); + + // The full value stays in the DOM regardless of visual clipping — confirm + // it's actually clipped at its current rendered width before relying on + // the tooltip to reveal it. + const isTruncated = await endpoint.evaluate((el) => el.scrollWidth > el.clientWidth); + expect(isTruncated).toBe(true); + + await expect(page.getByRole("tooltip")).toHaveCount(0); + await endpoint.hover(); + + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toHaveText(fullEndpointText!); + }); + test("details panel add source button navigates to edit the virtual server", async ({ page }) => { await page.route("**/servers?*", async (route) => { await route.fulfill({ diff --git a/openapi.json b/openapi.json index 30026ea8..0e9b23e4 100644 --- a/openapi.json +++ b/openapi.json @@ -36886,6 +36886,79 @@ } } } + }, + "/v1/virtual-servers/{server_id}/test-handshake": { + "post": { + "tags": [ + "Servers" + ], + "summary": "Test Server Mcp Handshake", + "description": "Test whether a virtual server's own MCP endpoint speaks MCP via a protocol handshake.\n\nUnlike ``POST /gateways/test-handshake``, the target isn't an arbitrary\ncaller-supplied URL \u2014 it's this server's own ``/servers/{server_id}/mcp``\ntransport, resolved from a server ID the caller already has read access to.\nThe handshake runs in-process (no outbound network call, no SSRF allowlist),\nreusing the caller's own forwarded credentials by default so the result\nreflects what that caller would actually see.\n\nArgs:\n server_id (str): The ID of the virtual server to test.\n request (Request): The incoming request, used for scoped access validation and to forward the caller's own credentials.\n body (ServerHandshakeRequest): Optional header overrides for the handshake.\n db (Session): The database session used to interact with the data store.\n user: Authenticated user context.\n\nReturns:\n GatewayHandshakeResponse: The handshake outcome, including negotiation path,\n server identity, capabilities, component counts, and failure classification.\n\nRaises:\n HTTPException: If the server is not found or the caller lacks visibility.", + "operationId": "test_server_mcp_handshake_v1_virtual_servers__server_id__test_handshake_post", + "security": [ + { + "ConfigurableHTTPBearer": [] + } + ], + "parameters": [ + { + "name": "server_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Server Id" + } + }, + { + "name": "jwt_token", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Jwt Token" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerHandshakeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GatewayHandshakeResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -53062,7 +53135,8 @@ "enum": [ "stored", "form", - "none" + "none", + "session" ], "title": "Credentialsource", "default": "none" @@ -53116,6 +53190,29 @@ "title": "GatewayHandshakeResponse", "description": "Result of an MCP handshake test.", "nullable": true + }, + "ServerHandshakeRequest": { + "properties": { + "headers": { + "anyOf": [ + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Headers", + "description": "Optional headers (e.g. Authorization) overriding the caller's own forwarded credentials" + } + }, + "type": "object", + "title": "ServerHandshakeRequest", + "description": "Request to run an MCP handshake test against a virtual server's own endpoint.\n\nUnlike :class:`GatewayHandshakeRequest`, the target is derived from the\ntrusted, already-registered virtual server ID (path parameter) rather than\nan arbitrary caller-supplied URL, so no ``base_url``/``path`` fields exist here.", + "nullable": true } }, "securitySchemes": { @@ -53129,4 +53226,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/api/servers.ts b/src/api/servers.ts index b131491c..4f7b6b91 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -140,6 +140,9 @@ export const serversApi = { * * Tries the stateless server/discover method (MCP 2026-07-28+) first and * falls back to a stateful initialize round-trip for earlier specs. + * + * Calls POST /v1/mcp-servers/test-handshake. Returns a structured + * GatewayHandshakeResponse describing the negotiation outcome. */ testHandshake: ( request: GatewayHandshakeRequest, diff --git a/src/api/virtualServers.test.ts b/src/api/virtualServers.test.ts index 154eccbf..5b22ab62 100644 --- a/src/api/virtualServers.test.ts +++ b/src/api/virtualServers.test.ts @@ -5,6 +5,7 @@ import { buildUpdateVirtualServerPayload, deleteVirtualServer, setVirtualServerState, + testVirtualServerHandshake, updateVirtualServerTags, } from "./virtualServers"; @@ -314,4 +315,37 @@ describe("virtualServers API", () => { expect(api.put).toHaveBeenCalledWith("/servers/team%2F1", { tags: ["x"] }); }); + + describe("testVirtualServerHandshake", () => { + it("POSTs to /v1/virtual-servers/{id}/test-handshake with the request body and signal", async () => { + const response = { success: true, latencyMs: 12, credentialSource: "session" }; + vi.mocked(api.post).mockResolvedValue(response); + const controller = new AbortController(); + + const result = await testVirtualServerHandshake( + "server-1", + { headers: { Authorization: "Bearer tok" } }, + controller.signal, + ); + + expect(api.post).toHaveBeenCalledWith( + "/v1/virtual-servers/server-1/test-handshake", + { headers: { Authorization: "Bearer tok" } }, + { signal: controller.signal }, + ); + expect(result).toBe(response); + }); + + it("URL-encodes the server ID", async () => { + vi.mocked(api.post).mockResolvedValue({ success: true, latencyMs: 1 }); + + await testVirtualServerHandshake("team/1", {}); + + expect(api.post).toHaveBeenCalledWith( + "/v1/virtual-servers/team%2F1/test-handshake", + {}, + { signal: undefined }, + ); + }); + }); }); diff --git a/src/api/virtualServers.ts b/src/api/virtualServers.ts index 9c597652..bab82638 100644 --- a/src/api/virtualServers.ts +++ b/src/api/virtualServers.ts @@ -1,6 +1,7 @@ import { api } from "@/api/client"; import type { CreateServerDetails } from "@/components/gateways/types"; import type { VirtualServer } from "@/types/server"; +import type { GatewayHandshakeResponse, ServerHandshakeRequest } from "@/generated/types"; export interface CreateVirtualServerPayload { server: { @@ -119,3 +120,24 @@ export function updateVirtualServer( export function updateVirtualServerTags(serverId: string, tags: string[]): Promise { return api.put(`/servers/${encodeURIComponent(serverId)}`, { tags }); } + +/** + * Test whether a virtual server's own MCP endpoint speaks MCP via a protocol handshake. + * + * Unlike the gateway-scoped {@link serversApi.testHandshake}, the target isn't a + * caller-supplied URL — the backend derives it from the server's own ID and + * dispatches in-process, reusing the caller's own forwarded credentials + * (session/bearer token) by default. `request.headers` overrides those + * credentials when provided. + * + * Calls POST /v1/virtual-servers/{id}/test-handshake. + */ +export function testVirtualServerHandshake( + serverId: string, + request: ServerHandshakeRequest, + signal?: AbortSignal, +): Promise { + return api.post(`/v1/virtual-servers/${encodeURIComponent(serverId)}/test-handshake`, request, { + signal, + }); +} diff --git a/src/components/gateways/VirtualServerDetailsPanel.test.tsx b/src/components/gateways/VirtualServerDetailsPanel.test.tsx index afbe8737..0993320d 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.test.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.test.tsx @@ -118,7 +118,9 @@ describe("VirtualServerDetailsPanel components list", () => { } it("renders titled and untitled component rows with badges", async () => { + const user = userEvent.setup(); renderWithComponents(); + await user.click(await screen.findByRole("tab", { name: "Components" })); // Titled tool row shows the display title and the id as the identifier. expect(await screen.findByText("Titled Tool")).toBeInTheDocument(); @@ -137,6 +139,7 @@ describe("VirtualServerDetailsPanel components list", () => { it("copies the identifier when a row's copy button is clicked", async () => { const user = userEvent.setup(); renderWithComponents(); + await user.click(await screen.findByRole("tab", { name: "Components" })); await screen.findByText("Titled Tool"); @@ -152,6 +155,7 @@ describe("VirtualServerDetailsPanel components list", () => { it("filters visible components with the search box", async () => { const user = userEvent.setup(); renderWithComponents(); + await user.click(await screen.findByRole("tab", { name: "Components" })); await screen.findByText("Titled Tool"); @@ -202,6 +206,8 @@ describe("VirtualServerDetailsPanel components list", () => { />, ); + await user.click(await screen.findByRole("tab", { name: "Components" })); + // Source tabs resolve from the gateways response. expect(await screen.findByRole("tab", { name: "Gateway A" })).toBeInTheDocument(); expect(screen.getByRole("tab", { name: "Gateway B" })).toBeInTheDocument(); @@ -225,6 +231,7 @@ describe("VirtualServerDetailsPanel components list", () => { it("moves the active tab with arrow keys", async () => { const user = userEvent.setup(); renderWithComponents(); + await user.click(await screen.findByRole("tab", { name: "Components" })); await screen.findByText("Titled Tool"); @@ -331,6 +338,7 @@ describe("VirtualServerDetailsPanel render variants", () => { }); it("shows an empty state when the server has no components", async () => { + const user = userEvent.setup(); render( { onAddSources={vi.fn()} />, ); + await user.click(await screen.findByRole("tab", { name: "Components" })); expect(await screen.findByText(/No components found/i)).toBeInTheDocument(); }); @@ -355,7 +364,7 @@ describe("VirtualServerDetailsPanel render variants", () => { onAddSources={vi.fn()} />, ); - await screen.findByRole("tab", { name: "All" }); + await screen.findByText(/^endpoint$/i); await user.keyboard("{Escape}"); @@ -363,6 +372,7 @@ describe("VirtualServerDetailsPanel render variants", () => { }); it("handles component responses returned as bare arrays", async () => { + const user = userEvent.setup(); mswServer.use( http.get("*/servers/:id/tools", () => HttpResponse.json([{ id: "t1", name: "arr_tool", originalName: "arr_tool" }]), @@ -379,6 +389,7 @@ describe("VirtualServerDetailsPanel render variants", () => { onAddSources={vi.fn()} />, ); + await user.click(await screen.findByRole("tab", { name: "Components" })); expect(await screen.findByText("arr_tool")).toBeInTheDocument(); }); @@ -394,6 +405,7 @@ describe("VirtualServerDetailsPanel render variants", () => { onAddSources={vi.fn()} />, ); + await user.click(await screen.findByRole("tab", { name: "Components" })); const allTab = await screen.findByRole("tab", { name: "All" }); allTab.focus(); await user.keyboard("{Enter}"); @@ -401,3 +413,188 @@ describe("VirtualServerDetailsPanel render variants", () => { expect(allTab).toHaveAttribute("aria-selected", "true"); }); }); + +describe("VirtualServerDetailsPanel test connection tab", () => { + const HANDSHAKE_ENDPOINT = "*/v1/virtual-servers/:serverId/test-handshake"; + + beforeEach(() => { + mswServer.use( + http.get("*/servers/:id/tools", () => HttpResponse.json({ tools: [] })), + http.get("*/servers/:id/resources", () => HttpResponse.json({ resources: [] })), + http.get("*/servers/:id/prompts", () => HttpResponse.json({ prompts: [] })), + ); + }); + + it("renders the Try it and Components top-level tabs", async () => { + render( + , + ); + + expect(await screen.findByRole("tab", { name: "Components" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Try it" })).toBeInTheDocument(); + }); + + it("switches to the test panel and shows the handshake form", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + + expect(screen.getByRole("button", { name: /^test connection$/i })).toBeInTheDocument(); + expect(screen.getByText(/run a test to open a mcp session/i)).toBeInTheDocument(); + }); + + it("runs a handshake and displays a successful result", async () => { + const user = userEvent.setup(); + mswServer.use( + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 42, + serverName: "Test MCP", + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect(screen.getByText(/latency: 42 ms/i)).toBeInTheDocument(); + }); + + it("flags a component-count mismatch using the panel's own aggregated counts", async () => { + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ tools: [{ id: "t1", name: "tool-1", originalName: "tool-1" }] }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 0 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect( + await screen.findByText(/counts don.t match the virtual server.s aggregate/i), + ).toBeInTheDocument(); + }); + + it("excludes disabled components from the aggregate used for the handshake comparison", async () => { + // The drawer's own queries pass include_inactive=true, but the handshake's + // component_counts only ever reflect enabled components — a disabled tool + // must not count toward the aggregate or it would permanently mismatch. + const user = userEvent.setup(); + mswServer.use( + http.get("*/servers/:id/tools", () => + HttpResponse.json({ + tools: [ + { id: "t1", name: "tool-1", originalName: "tool-1", enabled: true }, + { id: "t2", name: "tool-2", originalName: "tool-2", enabled: false }, + ], + }), + ), + http.post(HANDSHAKE_ENDPOINT, () => + HttpResponse.json({ + success: true, + latencyMs: 10, + componentCounts: { tools: 1 }, + }), + ), + ); + + render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Try it" })); + await user.click(screen.getByRole("button", { name: /^test connection$/i })); + + await waitFor(() => { + expect(screen.getByText(/^connection test$/i)).toBeInTheDocument(); + }); + expect(screen.queryByText(/counts don.t match/i)).not.toBeInTheDocument(); + }); + + it("resets to the try-it tab when a new server is selected", async () => { + const user = userEvent.setup(); + const { rerender } = render( + , + ); + + await user.click(await screen.findByRole("tab", { name: "Components" })); + expect(await screen.findByRole("tab", { name: "All" })).toBeInTheDocument(); + + // Simulate opening a different server — the panel resets to Try it. + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole("tab", { name: "Try it" })).toHaveAttribute("aria-selected", "true"); + }); + }); +}); diff --git a/src/components/gateways/VirtualServerDetailsPanel.tsx b/src/components/gateways/VirtualServerDetailsPanel.tsx index 76984ec2..b1ea4438 100644 --- a/src/components/gateways/VirtualServerDetailsPanel.tsx +++ b/src/components/gateways/VirtualServerDetailsPanel.tsx @@ -12,6 +12,8 @@ import { Search, Wrench, } from "lucide-react"; +import { HandshakeTestPanel } from "@/components/servers/HandshakeTestPanel"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VisibilityInfoPopover, getVisibilityIcon, @@ -44,6 +46,12 @@ const COMPONENT_FILTER_OPTIONS: Array<{ value: ComponentFilter; labelId: string { value: "prompts", labelId: "gateways.details.filter.prompts" }, ]; +type TopTab = "components" | "test"; + +// Segmented-control styling shared with MCPServerDetailsPanel +const SEGMENTED_TRIGGER_CLASS = + "flex-1 rounded-sm px-3 py-1.5 font-medium data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"; + interface Tool { id: string; name: string; @@ -52,6 +60,7 @@ interface Tool { description?: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } interface Resource { @@ -61,6 +70,7 @@ interface Resource { uri: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } interface Prompt { @@ -71,6 +81,7 @@ interface Prompt { description?: string; gatewayId?: string; gateway_id?: string; + enabled?: boolean; } type ComponentWithType = @@ -144,6 +155,7 @@ export function VirtualServerDetailsPanel({ const tagFallback = intl.formatMessage({ id: "gateways.details.tagFallback" }); const notSyncedYet = intl.formatMessage({ id: "gateways.card.notSyncedYet" }); const tags = (server?.tags ?? []).map((tag, index) => getTagDisplay(tag, index, tagFallback)); + const [topTab, setTopTab] = useState("test"); const [sourceFilter, setSourceFilter] = useState("all"); const [componentFilter, setComponentFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -274,6 +286,21 @@ export function VirtualServerDetailsPanel({ const allComponents = fetchedComponents.length > 0 ? fetchedComponents : fallbackComponents; + // The virtual server's own aggregated component counts, used to flag a + // mismatch against what the handshake test itself reports. The handshake + // counts come from tools/resources/prompts `list` calls against the live + // MCP endpoint, which only ever see enabled components — so a disabled + // component here must be excluded too, or a server with one disabled tool + // would show a permanent, spurious mismatch. + const aggregatedComponentCounts = useMemo(() => { + const counts: Record = { tools: 0, resources: 0, prompts: 0 }; + for (const component of allComponents) { + if (component.enabled === false) continue; + counts[component.type] = (counts[component.type] ?? 0) + 1; + } + return counts; + }, [allComponents]); + const sourceIds = useMemo( () => Array.from( @@ -316,9 +343,10 @@ export function VirtualServerDetailsPanel({ const componentsLoading = toolsLoading || resourcesLoading || promptsLoading; - // Reset filter and search when the panel opens or the selected server changes. + // Reset tab, filter and search when the panel opens or the selected server changes. useEffect(() => { if (!open) return; + setTopTab("test"); setSourceFilter("all"); setComponentFilter("all"); setSearchQuery(""); @@ -447,205 +475,238 @@ export function VirtualServerDetailsPanel({
- {(sourcesLoading || sourceTabs.length > 0) && ( -
- {[ - { - id: "all", - label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), - isTruncated: false, - fullValue: undefined as string | undefined, - }, - ...sourceTabs, - ].map((source, index, sources) => { - const isSelected = sourceFilter === source.id; - const tabButton = ( + setTopTab(v as TopTab)} + aria-label="Virtual server details view" + > + + + {intl.formatMessage({ id: "gateways.details.tryIt" })} + + + {intl.formatMessage({ id: "gateways.details.components" })} + + + + + + + + + {(sourcesLoading || sourceTabs.length > 0) && ( +
+ {[ + { + id: "all", + label: intl.formatMessage({ id: "gateways.details.filter.allSources" }), + isTruncated: false, + fullValue: undefined as string | undefined, + }, + ...sourceTabs, + ].map((source, index, sources) => { + const isSelected = sourceFilter === source.id; + const tabButton = ( + + ); + + return ( + + {tabButton} + {source.isTruncated && ( + {source.fullValue} + )} + + ); + })} +
+ )} + +
+
+ {COMPONENT_FILTER_OPTIONS.map((option) => ( + + ))} +
+
- ); - - return ( - - {tabButton} - {source.isTruncated && {source.fullValue}} - - ); - })} -
- )} - -
-
- {COMPONENT_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- - 0 ? 0 : -1} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - onFocus={() => setIsSearchExpanded(true)} - onBlur={() => setIsSearchExpanded(searchQuery.length > 0)} - placeholder={isSearchExpanded || searchQuery.length > 0 ? "Search..." : ""} - className={cn( - "h-8 rounded-md border-border bg-muted/50 text-sm shadow-none transition-[width,padding,color,background-color,border-color] duration-200 ease-out placeholder:text-muted-foreground focus-visible:bg-background", - isSearchExpanded || searchQuery.length > 0 - ? "w-48 px-3 text-foreground" - : "w-0 px-0 text-transparent caret-foreground border-transparent", - )} - /> -
-
- - {error && ( -
- {error.message} -
- )} - -
- {componentsLoading && ( -
-
- )} - {!componentsLoading && - visibleComponents.map((component) => { - const title = component.title; - const identifier = getComponentIdentifier(component); + {error && ( +
+ {error.message} +
+ )} - return ( +
+ {componentsLoading && (
- - - {getComponentIcon(component.type)} - - {getComponentLabel(component.type)} - - {title ? ( - <> - - {title} - - - {identifier} - - - - ) : ( - <> - - {identifier} - - -
- ); - })} + )} - {!componentsLoading && visibleComponents.length === 0 && ( -
- No {componentFilter === "all" ? "components" : componentFilter} found + {!componentsLoading && + visibleComponents.map((component) => { + const title = component.title; + const identifier = getComponentIdentifier(component); + + return ( +
+ + + {getComponentIcon(component.type)} + + {getComponentLabel(component.type)} + + {title ? ( + <> + + {title} + + + {identifier} + + + + ) : ( + <> + + {identifier} + + +
+ ); + })} + + {!componentsLoading && visibleComponents.length === 0 && ( +
+ No {componentFilter === "all" ? "components" : componentFilter} found +
+ )}
- )} -
+ +