fix(ui): show registry provider logos and capitalize registry labels (#12985)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-08 18:16:08 +02:00
1 parent f44ea4b6fa
commit d8bdc8553f
20 files changed
+416 -50

No files matched your search

+17
View File
@@ -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" } }),
);
}
+16
View File
@@ -227,6 +227,22 @@ export async function getInstalledRegistryProviderOptions(): Promise<RegistryPro
};
}
/** Logo of each registry provider type; empty whenever Registry is out of reach. */
export async function getRegistryProviderLogos(): Promise<
Record<string, string>
> {
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,
@@ -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<Record<string, string>>,
) => {
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();
});
});
@@ -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<Record<string, string>> =>
providersResponse?.data.some((provider) => provider.attributes.is_dynamic)
? getRegistryProviderLogos().catch(() => ({}))
: {};
const enrichProviders = (
providersResponse: ProvidersApiResponse | undefined,
schedulesByProviderId: Record<string, ScheduleAttributes>,
@@ -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),
};
}
@@ -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}
/>
@@ -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<Record<string, string>>;
}
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({
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={(initialData) =>
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
}
onOpenOrganizationWizard={openOrganizationWizard}
/>
<RegistryLogosContext value={registryLogos ?? null}>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={(initialData) =>
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</RegistryLogosContext>
</div>
)}
<ProviderWizardModal
@@ -0,0 +1,59 @@
import { useForm } from "react-hook-form";
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 type { AddProviderFormValues } from "@/types/formSchemas";
import { RadioGroupProvider } from "./radio-group-provider";
const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
function Selector() {
const form = useForm<AddProviderFormValues>();
return (
<RadioGroupProvider
control={form.control}
isInvalid={false}
registryAvailable
registryOptions={[
{
type: "vcf",
label: "Vcf",
logoUrl: LOGO_URL,
},
]}
/>
);
}
// 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(<Selector />);
// 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));
});
});
@@ -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<RadioGroupProviderProps> = ({
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{provider.registry ? (
<Avatar>
<AvatarImage
src={
provider.logoUrl?.startsWith("https://")
? provider.logoUrl
: undefined
}
alt=""
/>
<AvatarFallback>
<ProviderTypeIcon type={provider.value} size={26} />
</AvatarFallback>
</Avatar>
<RegistryProviderLogo
type={provider.value}
logoUrl={provider.logoUrl}
size={26}
/>
) : (
<ProviderTypeIcon type={provider.value} size={26} />
)}
@@ -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 (
<Avatar style={{ width: size, height: size }}>
<AvatarImage
src={logoUrl?.startsWith("https://") ? logoUrl : undefined}
alt=""
/>
<AvatarFallback>
<ProviderTypeIcon type={type} size={size} />
</AvatarFallback>
</Avatar>
);
}
@@ -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(
>
<EntityInfo
cloudProvider={provider.attributes.provider}
icon={
provider.attributes.is_dynamic ? (
<RegistryRowLogo type={provider.attributes.provider} />
) : undefined
}
entityAlias={provider.attributes.alias}
entityId={provider.attributes.uid}
nameAction={
@@ -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<string, string>) => void;
const logos = new Promise<Record<string, string>>((resolve) => {
resolveLogos = resolve;
});
const screen = await render(
<RegistryLogosContext value={logos}>
<RegistryRowLogo type="vcf" />
</RegistryLogosContext>,
);
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);
});
});
@@ -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<Promise<
Record<string, string>
> | null>(null);
function ResolvedRegistryRowLogo({
type,
logos,
}: {
type: ProviderType;
logos: Promise<Record<string, string>>;
}) {
const byType = use(logos);
const logoUrl = Object.hasOwn(byType, type) ? byType[type] : undefined;
return (
<RegistryProviderLogo type={type} logoUrl={logoUrl} size={ROW_LOGO_SIZE} />
);
}
/** 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 = <RegistryProviderLogo type={type} size={ROW_LOGO_SIZE} />;
if (!logos) return placeholder;
return (
<Suspense fallback={placeholder}>
<ResolvedRegistryRowLogo type={type} logos={logos} />
</Suspense>
);
}
@@ -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",
@@ -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 [
@@ -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<Record<string, string | undefined>>;
@@ -107,7 +108,7 @@ export function RegistryCredentialFields({
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{option}
{formatRegistryLabel(option)}
</SelectItem>
))}
</SelectContent>
+15
View File
@@ -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"]);
});
});
+9
View File
@@ -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);
}
+21
View File
@@ -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"]);
});
});
+5 -3
View File
@@ -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));
}
+2
View File
@@ -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<Record<string, string>>;
}
export function isProvidersOrganizationRow(