From d8bdc8553f29ca18c7c218e8dccc2d72144306cc Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:16:08 +0200 Subject: [PATCH] fix(ui): show registry provider logos and capitalize registry labels (#12985) --- ui/__tests__/msw/handlers/registry.ts | 17 ++++ ui/actions/registry/registry.ts | 16 +++ .../providers/providers-page.utils.test.ts | 97 +++++++++++++++++++ .../providers/providers-page.utils.ts | 30 ++++-- .../providers/providers-tab-content.tsx | 1 + .../providers/providers-accounts-view.tsx | 30 +++--- .../radio-group-provider.integration.test.tsx | 59 +++++++++++ .../providers/radio-group-provider.tsx | 25 ++--- .../providers/registry-provider-logo.tsx | 33 +++++++ .../providers/table/column-providers.tsx | 6 ++ .../registry-row-logo.integration.test.tsx | 36 +++++++ .../providers/table/registry-row-logo.tsx | 39 ++++++++ .../steps/dynamic-credentials-step.test.tsx | 16 +-- .../wizard/steps/dynamic-credentials-step.tsx | 3 +- .../workflow/provider-credential-fields.tsx | 3 +- ui/lib/registry/labels.test.ts | 15 +++ ui/lib/registry/labels.ts | 9 ++ ui/lib/registry/provider-options.test.ts | 21 ++++ ui/lib/registry/provider-options.ts | 8 +- ui/types/providers-table.ts | 2 + 20 files changed, 416 insertions(+), 50 deletions(-) create mode 100644 ui/__tests__/msw/handlers/registry.ts create mode 100644 ui/components/providers/radio-group-provider.integration.test.tsx create mode 100644 ui/components/providers/registry-provider-logo.tsx create mode 100644 ui/components/providers/table/registry-row-logo.integration.test.tsx create mode 100644 ui/components/providers/table/registry-row-logo.tsx create mode 100644 ui/lib/registry/labels.test.ts create mode 100644 ui/lib/registry/labels.ts diff --git a/ui/__tests__/msw/handlers/registry.ts b/ui/__tests__/msw/handlers/registry.ts new file mode 100644 index 0000000000..1cbc2c5b22 --- /dev/null +++ b/ui/__tests__/msw/handlers/registry.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse } from "msw"; + +// A 1×1 transparent PNG. +const PNG = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + ), + (char) => char.charCodeAt(0), +); + +/** Serves a registry logo image, so a test never reaches the real registry. */ +export function handleRegistryLogo(url: string) { + return http.get( + url, + () => new HttpResponse(PNG, { headers: { "Content-Type": "image/png" } }), + ); +} diff --git a/ui/actions/registry/registry.ts b/ui/actions/registry/registry.ts index 4d470bfc6b..18cd2322ce 100644 --- a/ui/actions/registry/registry.ts +++ b/ui/actions/registry/registry.ts @@ -227,6 +227,22 @@ export async function getInstalledRegistryProviderOptions(): Promise +> { + const access = (await auth())?.accessToken; + const permission = await evaluateRegistryProviderAccess(access); + if (!access || permission.status !== REGISTRY_ACCESS.ELIGIBLE) return {}; + const result = await readRegistryProviders(access, null); + if (result.status !== "ready") return {}; + return Object.fromEntries( + result.providers.flatMap(({ type, logoUrl }) => + logoUrl?.startsWith("https://") ? [[type, logoUrl]] : [], + ), + ); +} + async function readCompleteRegistryCatalog( accessToken: string, credential: RegistryCredentialStatus | null, diff --git a/ui/app/(prowler)/providers/providers-page.utils.test.ts b/ui/app/(prowler)/providers/providers-page.utils.test.ts index 2b1cbfad1a..b14af05297 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.test.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.test.ts @@ -22,6 +22,10 @@ const manageGroupsActionsMock = vi.hoisted(() => ({ getAllProviderGroups: vi.fn(), })); +const registryActionsMock = vi.hoisted(() => ({ + getRegistryProviderLogos: vi.fn(), +})); + vi.mock("@/actions/providers", () => providersActionsMock); vi.mock( "@/actions/organizations/organizations", @@ -30,6 +34,7 @@ vi.mock( vi.mock("@/actions/scans", () => scansActionsMock); vi.mock("@/actions/schedules", () => schedulesActionsMock); vi.mock("@/actions/manage-groups/manage-groups", () => manageGroupsActionsMock); +vi.mock("@/actions/registry/registry", () => registryActionsMock); import { SearchParamsProps } from "@/types"; import { NODE_KIND } from "@/types/organizations"; @@ -1313,4 +1318,96 @@ describe("loadProvidersAccountsViewData", () => { false, ); }); + + const givenRegistryProviderPage = ( + logos: Promise>, + ) => { + const registryProvider = { + ...providersResponse.data[1], + id: "provider-vcf", + attributes: { + ...providersResponse.data[1].attributes, + provider: "vcf", + is_dynamic: true, + }, + }; + providersActionsMock.getProviders.mockResolvedValue({ + ...providersResponse, + data: [providersResponse.data[0], registryProvider], + }); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + schedulesActionsMock.getSchedules.mockResolvedValue({ data: [] }); + organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ + data: [], + }); + organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({ + data: [], + }); + registryActionsMock.getRegistryProviderLogos.mockReturnValue(logos); + }; + + it("streams the logo the registry publishes for each provider type", async () => { + // Given + givenRegistryProviderPage( + Promise.resolve({ + vcf: "https://media.registry.example.com/providers/vcf/logo.png", + }), + ); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: true, + }); + + // Then + await expect(viewData.registryLogos).resolves.toEqual({ + vcf: "https://media.registry.example.com/providers/vcf/logo.png", + }); + }); + + it("returns the provider rows without waiting for registry logos", async () => { + // Given: a registry that never answers. + givenRegistryProviderPage(new Promise(() => {})); + + // When + const result = await Promise.race([ + loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: true, + }), + new Promise<"blocked">((resolve) => + setTimeout(() => resolve("blocked"), 200), + ), + ]); + + // Then + expect(result).not.toBe("blocked"); + if (result === "blocked") return; + expect(findProviderRow(result.rows, "provider-vcf")).toBeDefined(); + }); + + it("does not ask the registry for logos when the page lists no registry providers", async () => { + // Given + registryActionsMock.getRegistryProviderLogos.mockClear(); + providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); + schedulesActionsMock.getSchedules.mockResolvedValue({ data: [] }); + organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ + data: [], + }); + organizationsActionsMock.listOrganizationNodesSafe.mockResolvedValue({ + data: [], + }); + + // When + const viewData = await loadProvidersAccountsViewData({ + searchParams: {} satisfies SearchParamsProps, + isCloud: true, + }); + + // Then + await expect(viewData.registryLogos).resolves.toEqual({}); + expect(registryActionsMock.getRegistryProviderLogos).not.toHaveBeenCalled(); + }); }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 56cc22e7ca..e867998cf9 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -5,6 +5,7 @@ import { } from "@/actions/organizations/organizations"; import { getAllProviders, getProviders } from "@/actions/providers"; import { PROVIDERS_FILTER_PARAM } from "@/actions/providers/providers-filters"; +import { getRegistryProviderLogos } from "@/actions/registry/registry"; import { getSchedules } from "@/actions/schedules"; import { extractFiltersAndQuery, @@ -142,6 +143,14 @@ const getProviderLastScanAt = ( return provider.attributes.connection.last_checked_at ?? null; }; +// Only a page that lists a registry provider pays for the registry round trip. +const loadRegistryLogos = async ( + providersResponse: ProvidersApiResponse | undefined, +): Promise> => + providersResponse?.data.some((provider) => provider.attributes.is_dynamic) + ? getRegistryProviderLogos().catch(() => ({})) + : {}; + const enrichProviders = ( providersResponse: ProvidersApiResponse | undefined, schedulesByProviderId: Record, @@ -557,6 +566,16 @@ export async function loadProvidersAccountsViewData({ data: [], }; + const providersRequest = resolveActionResult( + getProviders({ + filters: providerFilters, + page, + pageSize, + query, + sort: encodedSort, + }), + ); + const [ providersResponse, allProvidersResponse, @@ -565,15 +584,7 @@ export async function loadProvidersAccountsViewData({ organizationsResponse, organizationNodesResponse, ] = await Promise.all([ - resolveActionResult( - getProviders({ - filters: providerFilters, - page, - pageSize, - query, - sort: encodedSort, - }), - ), + providersRequest, // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. resolveActionResult(getAllProviders()), @@ -619,6 +630,7 @@ export async function loadProvidersAccountsViewData({ providerGroups: allProviderGroupsResponse?.data ?? [], rows, hierarchyStatus, + registryLogos: providersRequest.then(loadRegistryLogos), }; } diff --git a/ui/app/(prowler)/providers/providers-tab-content.tsx b/ui/app/(prowler)/providers/providers-tab-content.tsx index c55f8730c6..0d88ca8097 100644 --- a/ui/app/(prowler)/providers/providers-tab-content.tsx +++ b/ui/app/(prowler)/providers/providers-tab-content.tsx @@ -50,6 +50,7 @@ export const ProvidersTabContent = async ({ metadata={providersView.metadata} rows={providersView.rows} hierarchyStatus={providersView.hierarchyStatus} + registryLogos={providersView.registryLogos} scanConfigs={scanConfigsState.data} scanConfigStatus={scanConfigsState.status} /> diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index b94202af38..d742b58cbf 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -10,6 +10,7 @@ import { MutedFindingsConfigButton } from "@/components/providers/muted-findings import { NoProvidersAdded } from "@/components/providers/no-providers-added"; import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table"; import { ProvidersFilters } from "@/components/providers/providers-filters"; +import { RegistryLogosContext } from "@/components/providers/table/registry-row-logo"; import { ProviderWizardModal } from "@/components/providers/wizard"; import type { OrgWizardInitialData, @@ -81,6 +82,8 @@ interface ProvidersAccountsViewProps { scanConfigStatus?: ScanConfigurationListStatus; isScanLimitReached?: boolean; hierarchyStatus?: HierarchyStatus; + /** Registry logo per provider type; rows render before it resolves. */ + registryLogos?: Promise>; } export function ProvidersAccountsView({ @@ -95,6 +98,7 @@ export function ProvidersAccountsView({ scanConfigStatus, isScanLimitReached, hierarchyStatus = HIERARCHY_STATUS.AVAILABLE, + registryLogos, }: ProvidersAccountsViewProps) { const pathname = usePathname(); const searchParams = useSearchParams(); @@ -219,18 +223,20 @@ export function ProvidersAccountsView({ } /> - - openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData) - } - onOpenOrganizationWizard={openOrganizationWizard} - /> + + + openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData) + } + onOpenOrganizationWizard={openOrganizationWizard} + /> + )} (); + return ( + + ); +} + +// The logo is the first element of the option's label row. +function logoBox(option: HTMLElement) { + const logo = option.lastElementChild?.firstElementChild; + if (!logo) throw new Error("Provider option without a logo"); + const { width, height } = logo.getBoundingClientRect(); + return { width, height }; +} + +describe("provider selector logos", () => { + it("draws a Registry provider's logo at the size of the built-in logos", async () => { + // Given + worker.use(handleRegistryLogo(LOGO_URL)); + const screen = await render(); + + // When + const builtIn = screen + .getByRole("option", { name: "Amazon Web Services" }) + .element() as HTMLElement; + const registry = screen + .getByRole("option", { name: "Vcf Registry" }) + .element() as HTMLElement; + await expect + .poll(() => registry.querySelector("img")?.getAttribute("src")) + .toBe(LOGO_URL); + + // Then + expect(logoBox(registry)).toEqual(logoBox(builtIn)); + }); +}); diff --git a/ui/components/providers/radio-group-provider.tsx b/ui/components/providers/radio-group-provider.tsx index f1d175da6e..a12535c3d5 100644 --- a/ui/components/providers/radio-group-provider.tsx +++ b/ui/components/providers/radio-group-provider.tsx @@ -8,11 +8,6 @@ import { PROVIDER_TYPE_DATA, } from "@/components/icons/providers-badge/provider-type-icon"; import { Badge, SearchInput } from "@/components/shadcn"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@/components/shadcn/avatar"; import { FormMessage } from "@/components/shadcn/form"; import { Tabs, @@ -24,6 +19,8 @@ import type { RegistryProviderOption } from "@/lib/registry/provider-options"; import { cn } from "@/lib/utils"; import type { AddProviderFormValues } from "@/types/formSchemas"; +import { RegistryProviderLogo } from "./registry-provider-logo"; + const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map( ([value, { label }]) => ({ value, label }), ); @@ -135,19 +132,11 @@ export const RadioGroupProvider: FC = ({
{provider.registry ? ( - - - - - - + ) : ( )} diff --git a/ui/components/providers/registry-provider-logo.tsx b/ui/components/providers/registry-provider-logo.tsx new file mode 100644 index 0000000000..1d80c34319 --- /dev/null +++ b/ui/components/providers/registry-provider-logo.tsx @@ -0,0 +1,33 @@ +import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/shadcn/avatar"; +import type { ProviderType } from "@/types"; + +interface RegistryProviderLogoProps { + type: ProviderType; + logoUrl?: string; + size: number; +} + +/** The registry's logo, or the generic glyph while it loads or once its signed URL expires. */ +export function RegistryProviderLogo({ + type, + logoUrl, + size, +}: RegistryProviderLogoProps) { + // Same box as a built-in provider badge of this size. + return ( + + + + + + + ); +} diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 548dda2e82..bb9efeda91 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -41,6 +41,7 @@ import type { import { LinkToScans } from "../link-to-scans"; import { DataTableRowActions } from "./data-table-row-actions"; +import { RegistryRowLogo } from "./registry-row-logo"; interface GroupNameChipsProps { groupNames?: string[]; @@ -247,6 +248,11 @@ export function getColumnProviders( > + ) : undefined + } entityAlias={provider.attributes.alias} entityId={provider.attributes.uid} nameAction={ diff --git a/ui/components/providers/table/registry-row-logo.integration.test.tsx b/ui/components/providers/table/registry-row-logo.integration.test.tsx new file mode 100644 index 0000000000..0831b144d5 --- /dev/null +++ b/ui/components/providers/table/registry-row-logo.integration.test.tsx @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { handleRegistryLogo } from "@/__tests__/msw/handlers/registry"; +import { worker } from "@/__tests__/msw/worker"; +import { render } from "@/__tests__/render-browser"; + +import { RegistryLogosContext, RegistryRowLogo } from "./registry-row-logo"; + +const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png"; + +describe("registry row logo", () => { + it("shows the generic glyph until the streamed logos arrive, then the registry logo", async () => { + // Given + worker.use(handleRegistryLogo(LOGO_URL)); + let resolveLogos!: (logos: Record) => void; + const logos = new Promise>((resolve) => { + resolveLogos = resolve; + }); + const screen = await render( + + + , + ); + const logoImage = () => screen.container.querySelector("img"); + + // Then: the row renders while the registry has not answered. + expect(screen.container.querySelector("svg")).not.toBeNull(); + expect(logoImage()).toBeNull(); + + // When + resolveLogos({ vcf: LOGO_URL }); + + // Then + await expect.poll(() => logoImage()?.getAttribute("src")).toBe(LOGO_URL); + }); +}); diff --git a/ui/components/providers/table/registry-row-logo.tsx b/ui/components/providers/table/registry-row-logo.tsx new file mode 100644 index 0000000000..de555b1707 --- /dev/null +++ b/ui/components/providers/table/registry-row-logo.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { createContext, Suspense, use } from "react"; + +import { RegistryProviderLogo } from "@/components/providers/registry-provider-logo"; +import type { ProviderType } from "@/types"; + +const ROW_LOGO_SIZE = 35; + +/** Registry logo per provider type, resolved after the rows render. */ +export const RegistryLogosContext = createContext +> | null>(null); + +function ResolvedRegistryRowLogo({ + type, + logos, +}: { + type: ProviderType; + logos: Promise>; +}) { + const byType = use(logos); + const logoUrl = Object.hasOwn(byType, type) ? byType[type] : undefined; + return ( + + ); +} + +/** A registry row's logo; the generic glyph stands in until the logos arrive. */ +export function RegistryRowLogo({ type }: { type: ProviderType }) { + const logos = use(RegistryLogosContext); + const placeholder = ; + if (!logos) return placeholder; + return ( + + + + ); +} diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx index f91e5af082..710e562e17 100644 --- a/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.test.tsx @@ -157,12 +157,16 @@ describe("dynamic credentials in the provider wizard", () => { expect(timeout).toHaveAttribute("min", "1"); expect(timeout).toHaveAttribute("max", "300"); expect(timeout).toHaveAttribute("step", "1"); - expect( - screen.getByRole("combobox", { name: "Authentication Scheme" }), - ).toHaveTextContent("bearer"); + const authScheme = screen.getByRole("combobox", { + name: "Authentication Scheme", + }); + expect(authScheme).toHaveTextContent("Bearer"); expect(apiUrl).toHaveValue(""); - // When: false must remain a boolean and numeric input must become a number. + // When: options read capitalized but submit their raw schema value. + await user.click(authScheme); + await user.click(screen.getByRole("option", { name: "Basic" })); + // False must remain a boolean and numeric input must become a number. await user.type(apiUrl, "https://api.example.test"); await user.type(apiKey, "fixture-key-not-a-secret"); await user.click(verifyTls); @@ -180,7 +184,7 @@ describe("dynamic credentials in the provider wizard", () => { api_key: "fixture-key-not-a-secret", verify_tls: false, timeout_seconds: 60, - auth_scheme: "bearer", + auth_scheme: "basic", }, }); }); @@ -347,7 +351,7 @@ describe("dynamic credentials in the provider wizard", () => { await user.click( screen.getByRole("combobox", { name: "Authentication method" }), ); - await user.click(screen.getByRole("option", { name: "personal token" })); + await user.click(screen.getByRole("option", { name: "Personal token" })); expect(screen.getByLabelText(/API token/)).toHaveValue(""); expect(JSON.stringify(sessionStorage)).not.toContain( "previous-method-secret", diff --git a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx index 3d2c48941d..f8732b2853 100644 --- a/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx +++ b/ui/components/providers/wizard/steps/dynamic-credentials-step.tsx @@ -27,6 +27,7 @@ import { getCredentialDefaults, validateCredentialValues, } from "@/lib/provider-credentials/provider-credential-values"; +import { formatRegistryLabel } from "@/lib/registry/labels"; import { useProviderWizardStore } from "@/store/provider-wizard/store"; import type { ProviderSchemasResult, @@ -58,7 +59,7 @@ function listCredentialMethods( secretTypes: ProviderSecretTypes, ): CredentialMethod[] { return Object.entries(secretTypes).flatMap(([secretType, value]) => { - const label = secretType.replaceAll("_", " "); + const label = formatRegistryLabel(secretType); const variants = parseRegistryCredentialVariants(value); if (!variants || variants.length === 1) { return [ diff --git a/ui/components/providers/workflow/provider-credential-fields.tsx b/ui/components/providers/workflow/provider-credential-fields.tsx index 230f36b0e4..5466af1b9e 100644 --- a/ui/components/providers/workflow/provider-credential-fields.tsx +++ b/ui/components/providers/workflow/provider-credential-fields.tsx @@ -17,6 +17,7 @@ import type { RegistryCredentialSchema, RegistryCredentialValue, } from "@/lib/provider-credentials/provider-credential-schema"; +import { formatRegistryLabel } from "@/lib/registry/labels"; interface RegistryCredentialFieldsProps { readonly errors: Readonly>; @@ -107,7 +108,7 @@ export function RegistryCredentialFields({ {field.options?.map((option) => ( - {option} + {formatRegistryLabel(option)} ))} diff --git a/ui/lib/registry/labels.test.ts b/ui/lib/registry/labels.test.ts new file mode 100644 index 0000000000..605ee27a82 --- /dev/null +++ b/ui/lib/registry/labels.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { formatRegistryLabel } from "./labels"; + +describe("formatRegistryLabel", () => { + it("capitalizes plain identifiers and keeps any other value as written", () => { + // When + const labels = ["token", "service_account", "us-east-1", "OAuth2"].map( + formatRegistryLabel, + ); + + // Then + expect(labels).toEqual(["Token", "Service account", "us-east-1", "OAuth2"]); + }); +}); diff --git a/ui/lib/registry/labels.ts b/ui/lib/registry/labels.ts new file mode 100644 index 0000000000..e029c9c9ae --- /dev/null +++ b/ui/lib/registry/labels.ts @@ -0,0 +1,9 @@ +// Only plain lowercase identifiers: "us-east-1" or "OAuth2" already read as meant. +const PLAIN_IDENTIFIER = /^[a-z][a-z0-9]*(?:[_ ][a-z0-9]+)*$/; + +/** Display text for a Registry identifier ("service_account" → "Service account"). */ +export function formatRegistryLabel(value: string): string { + if (!PLAIN_IDENTIFIER.test(value)) return value; + const words = value.replaceAll("_", " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} diff --git a/ui/lib/registry/provider-options.test.ts b/ui/lib/registry/provider-options.test.ts index ecc46985b6..cda44cbffa 100644 --- a/ui/lib/registry/provider-options.test.ts +++ b/ui/lib/registry/provider-options.test.ts @@ -143,4 +143,25 @@ describe("installed Registry provider options", () => { ).map((option) => option.type), ).toEqual(["zeta"]); }); + + it("capitalizes provider names the registry publishes as plain slugs", () => { + // Given: the registry names one provider by its slug and another properly. + const catalog = [ + { ...provider, normalizedName: "vcf-package", providerSlug: "vcf" }, + { ...provider, normalizedName: "openai-package", providerSlug: "openai" }, + ]; + const installed = catalog.map(({ normalizedName }) => ({ + normalizedName, + versionSpec: "latest", + })); + + // When + const labels = buildRegistryProviderOptions(catalog, installed, [ + { type: "vcf", label: "vcf" }, + { type: "openai", label: "OpenAI" }, + ]).map((option) => option.label); + + // Then + expect(labels).toEqual(["OpenAI", "Vcf"]); + }); }); diff --git a/ui/lib/registry/provider-options.ts b/ui/lib/registry/provider-options.ts index cfe391480f..0c586484df 100644 --- a/ui/lib/registry/provider-options.ts +++ b/ui/lib/registry/provider-options.ts @@ -1,3 +1,4 @@ +import { formatRegistryLabel } from "@/lib/registry/labels"; import { isKnownProviderType } from "@/types/providers"; import type { RegistryCatalogArtifact, @@ -63,7 +64,8 @@ export function buildRegistryProviderOptions( ); } } - return Array.from(options.values()).sort((left, right) => - left.label.localeCompare(right.label), - ); + return Array.from(options.values(), (option) => ({ + ...option, + label: formatRegistryLabel(option.label), + })).sort((left, right) => left.label.localeCompare(right.label)); } diff --git a/ui/types/providers-table.ts b/ui/types/providers-table.ts index 48da50e5c3..e1628545f7 100644 --- a/ui/types/providers-table.ts +++ b/ui/types/providers-table.ts @@ -105,6 +105,8 @@ export interface ProvidersAccountsViewData { rows: ProvidersTableRow[]; /** `unavailable` when the hierarchy fetch failed (drives the degraded notice). */ hierarchyStatus: HierarchyStatus; + /** Registry logo per provider type, streamed so rows never wait for it. */ + registryLogos: Promise>; } export function isProvidersOrganizationRow(