From 9e619c49a7b018e5f4ddfce9df216d72f8397fd6 Mon Sep 17 00:00:00 2001 From: Iaroslav Gryshaiev Date: Fri, 12 Jun 2026 13:46:15 +0000 Subject: [PATCH] feat(deployment): add screened provider marketplace to configure screen Populate the Compute Marketplace pane with audited providers screened for the selected placement's group spec, and drive it from the deployment pane's service/placement selection. API / SDK - expose the screenProviders operationId on POST /v1/bid-screening and surface it in the typed console-api-types SDK (incl. the location response field) - validate (don't transform) resource values in the request schema so the proxy forwards them without a BigInt serialization error - register the "screen" domain verb for the operation-id-format lint rule Marketplace (deploy-web) - useScreenedProviders: screens the selected placement's group spec (audited via signedBy, region via attributes), falling back to the full audited catalog (empty resources) while the SDL is mid-edit/invalid - MarketplaceProvidersTable: design-system table with sortable Provider/Region columns and an empty Cost column with an estimate tooltip; host formatted like the bid list via getProviderNameFromUri - MarketplacePane keeps the cached table on a refetch error; shows the error only when there is nothing to display Deployment pane selection - clicking a service selects it; clicking a placement selects its first service; adding a placement adds a default service; selected service/placement states - resolve the selected placement in the form and pass it to the marketplace - normalize a service-less carried-in SDL to a default at the configure entry, so the selected service/placement are always present (required, non-nullable) - require the service id in the form schema (always generated) to drop id casts --- .../bid-screening.controller.spec.ts | 14 +++ .../http-schemas/bid-screening.schema.ts | 20 ++-- .../routes/bid-screening.router.ts | 1 + apps/api/swagger/openapi.json | 10 +- .../__snapshots__/docs.spec.ts.snap | 1 + .../ConfigurationPane.spec.tsx | 8 +- .../ConfigurationPane/ConfigurationPane.tsx | 2 +- .../ConfigureDeploymentForm.spec.tsx | 18 ++- .../ConfigureDeploymentForm.tsx | 75 +++++++++--- .../ConfigureDeploymentPanes.spec.tsx | 17 ++- .../ConfigureDeploymentPanes.tsx | 7 +- .../DeploymentPane/DeploymentPane.spec.tsx | 8 +- .../DeploymentPane/DeploymentPane.tsx | 4 +- .../PlacementCard/PlacementCard.spec.tsx | 23 +++- .../PlacementCard/PlacementCard.tsx | 34 +++++- .../DeploymentPane/ServiceRow/ServiceRow.tsx | 9 +- .../usePlacementManager.spec.tsx | 9 +- .../usePlacementManager.ts | 8 +- .../MarketplacePane/MarketplacePane.spec.tsx | 63 ++++++++++ .../MarketplacePane/MarketplacePane.tsx | 27 ++++- .../MarketplaceProvidersTable.spec.tsx | 64 ++++++++++ .../MarketplaceProvidersTable.tsx | 105 +++++++++++++++++ .../src/queries/useScreenedProviders.spec.tsx | 111 ++++++++++++++++++ .../src/queries/useScreenedProviders.ts | 89 ++++++++++++++ .../src/types/sdlBuilder/sdlBuilder.spec.ts | 9 +- .../src/types/sdlBuilder/sdlBuilder.ts | 2 +- .../console-api-types/src/operations.gen.ts | 1 + packages/console-api-types/src/schema.d.ts | 5 + packages/dev-config/.eslintrc.base.js | 2 +- 29 files changed, 675 insertions(+), 71 deletions(-) create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplacePane.spec.tsx create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.spec.tsx create mode 100644 apps/deploy-web/src/components/deployments/ConfigureDeployment/MarketplacePane/MarketplaceProvidersTable/MarketplaceProvidersTable.tsx create mode 100644 apps/deploy-web/src/queries/useScreenedProviders.spec.tsx create mode 100644 apps/deploy-web/src/queries/useScreenedProviders.ts diff --git a/apps/api/src/bid-screening/controllers/bid-screening/bid-screening.controller.spec.ts b/apps/api/src/bid-screening/controllers/bid-screening/bid-screening.controller.spec.ts index d8e306523a..8d38345799 100644 --- a/apps/api/src/bid-screening/controllers/bid-screening/bid-screening.controller.spec.ts +++ b/apps/api/src/bid-screening/controllers/bid-screening/bid-screening.controller.spec.ts @@ -4,6 +4,7 @@ import { mock } from "vitest-mock-extended"; import type { BidScreeningConfig } from "@src/bid-screening/config/env.config"; import type { FeatureFlagsService } from "@src/core/services/feature-flags/feature-flags.service"; import type { BidScreeningRequest } from "../../http-schemas/bid-screening.schema"; +import { BidScreeningRequestSchema } from "../../http-schemas/bid-screening.schema"; import { BidScreeningController } from "./bid-screening.controller"; describe(BidScreeningController.name, () => { @@ -27,6 +28,19 @@ describe(BidScreeningController.name, () => { expect(JSON.parse(init?.body as string)).toEqual(request); }); + it("forwards a schema-parsed request body without BigInt serialization errors", async () => { + const fetchSpy = mockFetchJson({ providers: [] }); + const { controller } = setup({}); + + const parsed = BidScreeningRequestSchema.parse(makeRequest()); + await controller.screenProviders(parsed); + + const [, init] = fetchSpy.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.resources[0].resource.cpu.units.val).toBe("1000"); + expect(body.resources[0].resource.gpu.units.val).toBe("0"); + }); + it("short-circuits to an empty list when the feature flag is disabled", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const { controller } = setup({ flagEnabled: false }); diff --git a/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts index d7283dd68f..af61a1b752 100644 --- a/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts +++ b/apps/api/src/bid-screening/http-schemas/bid-screening.schema.ts @@ -2,20 +2,20 @@ import { z } from "@hono/zod-openapi"; const UIntStringSchema = z.string().regex(/^\d+$/, "Must be an unsigned integer string"); +/** + * Validates the resource value but does NOT transform it. This service only proxies the request to + * provider-inventory, which parses the value itself; transforming to BigInt here would break the + * `JSON.stringify` forward in the controller. Value is a non-negative integer string or its + * protobuf base64-encoded representation. + */ const ResourceValueSchema = z.object({ val: z .string() .max(80) - .transform(str => { - if (/^\d+$/.test(str)) return BigInt(str); - const parsed = Buffer.from(str, "base64").toString("utf-8"); - if (/^\d+$/.test(parsed)) return BigInt(parsed); - return NaN; - }) - .refine( - (val): val is bigint => !Number.isFinite(val) && typeof val === "bigint" && val >= 0n, - "Must be a non-negative integer or its protobuf base64-encoded representation" - ) + .refine(str => { + const decoded = /^\d+$/.test(str) ? str : Buffer.from(str, "base64").toString("utf-8"); + return /^\d+$/.test(decoded); + }, "Must be a non-negative integer or its protobuf base64-encoded representation") }); // Mirrors AttributeNameRegexpStringWildcard in akash-network/chain-sdk diff --git a/apps/api/src/bid-screening/routes/bid-screening.router.ts b/apps/api/src/bid-screening/routes/bid-screening.router.ts index a330efaadf..c8ea318f9a 100644 --- a/apps/api/src/bid-screening/routes/bid-screening.router.ts +++ b/apps/api/src/bid-screening/routes/bid-screening.router.ts @@ -10,6 +10,7 @@ export const bidScreeningRouter = new OpenApiHonoHandler(); const postBidScreeningRoute = createRoute({ method: "post", + operationId: "screenProviders", path: "/v1/bid-screening", summary: "Screen providers by deployment resource requirements", tags: ["Bid Screening"], diff --git a/apps/api/swagger/openapi.json b/apps/api/swagger/openapi.json index e565a4fc5c..f8e30425fc 100644 --- a/apps/api/swagger/openapi.json +++ b/apps/api/swagger/openapi.json @@ -16378,6 +16378,7 @@ }, "/v1/bid-screening": { "post": { + "operationId": "screenProviders", "summary": "Screen providers by deployment resource requirements", "tags": [ "Bid Screening" @@ -16736,13 +16737,20 @@ "format": "date-time", "description": "ISO 8601 timestamp marking when the provider was first enrolled in the inventory", "example": "2026-01-01T00:00:00.000Z" + }, + "location": { + "type": "string", + "description": "Provider region from the location-region attribute (signed preferred, else self-declared); null if unset", + "example": "us-west", + "nullable": true } }, "required": [ "owner", "hostUri", "isAudited", - "createdAt" + "createdAt", + "location" ] } } diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 15bcc42a63..1ed594bb64 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -2739,6 +2739,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` }, "/v1/bid-screening": { "post": { + "operationId": "screenProviders", "requestBody": { "content": { "application/json": { diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.spec.tsx index 05ad256024..17e59105b5 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.spec.tsx @@ -11,19 +11,19 @@ import { render, screen } from "@testing-library/react"; describe("ConfigurationPane", () => { it("shows the selected service title", () => { const values = defaultServiceWithPlacement({ title: "api" }); - setup({ values, selectedServiceId: values.services[0].id as string }); + setup({ values, selectedServiceId: values.services[0].id }); expect(screen.getByText("api")).toBeInTheDocument(); }); - it("shows no target when nothing is selected", () => { + it("shows no target when the selection matches no service", () => { const values = defaultServiceWithPlacement({ title: "api" }); - setup({ values, selectedServiceId: null }); + setup({ values, selectedServiceId: "missing" }); expect(screen.queryByText("api")).not.toBeInTheDocument(); }); - function setup(input: { values: SdlBuilderFormValuesType; selectedServiceId: string | null }) { + function setup(input: { values: SdlBuilderFormValuesType; selectedServiceId: string }) { const Wrapper = ({ children }: PropsWithChildren) => { const form = useForm({ defaultValues: input.values }); return {children}; diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.tsx index cbe7e435d8..1d163cdc96 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigurationPane/ConfigurationPane.tsx @@ -4,7 +4,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import type { SdlBuilderFormValuesType } from "@src/types"; type Props = { - selectedServiceId: string | null; + selectedServiceId: string; }; export const ConfigurationPane: FC = ({ selectedServiceId }) => { diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx index 6e490749bb..8c2724d549 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentForm/ConfigureDeploymentForm.spec.tsx @@ -100,9 +100,19 @@ const TWO_SERVICE_SDL = [ describe(ConfigureDeploymentForm.name, () => { it("seeds the panes with the carried-in template SDL", () => { - const { ConfigureDeploymentPanes } = setup({ initialSdl: 'version: "2.0"' }); + const { ConfigureDeploymentPanes } = setup({ initialSdl: VALID_SDL }); + + expect(ConfigureDeploymentPanes).toHaveBeenCalledWith(expect.objectContaining({ sdl: VALID_SDL }), expect.anything()); + }); + + it("falls back to a default deployment when the carried-in SDL has no services", () => { + const { ConfigureDeploymentPanes, enqueueSnackbar } = setup({ initialSdl: 'version: "2.0"' }); - expect(ConfigureDeploymentPanes).toHaveBeenCalledWith(expect.objectContaining({ sdl: 'version: "2.0"' }), expect.anything()); + expect(enqueueSnackbar).not.toHaveBeenCalled(); + expect(ConfigureDeploymentPanes).toHaveBeenCalledWith( + expect.objectContaining({ sdl: expect.stringContaining('version: "2.0"'), selectedServiceId: expect.any(String) }), + expect.anything() + ); }); it("generates the SDL of the default deployment when no SDL was provided", () => { @@ -180,7 +190,7 @@ describe(ConfigureDeploymentForm.name, () => { interface ProbePanesProps { sdl: string; - selectedServiceId: string | null; + selectedServiceId: string; } /** Panes stand-in that mutates the shared form to drive the SDL preview subscription. */ @@ -210,7 +220,7 @@ function SelectionProbePanes({ selectedServiceId }: ProbePanesProps) { const firstServiceId = Array.isArray(services) ? (services as SdlBuilderFormValuesType["services"])[0]?.id : undefined; return (
-
{selectedServiceId ?? ""}
+
{selectedServiceId}
{firstServiceId ?? ""}
- +
@@ -93,29 +101,39 @@ export const ConfigureDeploymentForm: FC = ({ initialSdl, dependencies: d interface InitialState { values: SdlBuilderFormValuesType; sdl: string; - selectedServiceId: string | null; + selectedServiceId: string; importError?: string; } /** - * Derives the form values, preview SDL, and initial selection from one source - * so they can never diverge. A parseable carried-in template keeps its exact - * SDL text for the preview; if it can't be imported the screen falls back to a - * default deployment and reports `importError` so the failure is surfaced - * rather than silently swallowed. + * Derives the form values, preview SDL, and initial selection from one source so they can never diverge. + * A carried-in SDL is used only when it imports to a usable deployment (at least one visible service); + * otherwise — invalid YAML, or a service-less SDL the configure screen can't work with — the screen falls + * back to a default deployment. This guarantees there is always a service (and placement) to select. */ function getInitialState(carriedInSdl: string | undefined): InitialState { if (carriedInSdl) { try { const values = importSimpleSdl(carriedInSdl); - return { values, sdl: carriedInSdl, selectedServiceId: seedSelectedServiceId(values) }; + if (hasVisibleService(values)) { + return { values, sdl: carriedInSdl, selectedServiceId: seedSelectedServiceId(values) }; + } } catch (error) { - const values = defaultServiceWithPlacement(); - return { values, sdl: regenerateSdl(values, ""), selectedServiceId: seedSelectedServiceId(values), importError: getImportErrorMessage(error) }; + return defaultInitialState(getImportErrorMessage(error)); } } + return defaultInitialState(); +} + +/** A fresh default deployment, optionally annotated with the error that made an import unusable. */ +function defaultInitialState(importError?: string): InitialState { const values = defaultServiceWithPlacement(); - return { values, sdl: regenerateSdl(values, ""), selectedServiceId: seedSelectedServiceId(values) }; + return { values, sdl: regenerateSdl(values, ""), selectedServiceId: seedSelectedServiceId(values), importError }; +} + +/** A usable deployment has at least one service the user can configure (log collectors don't count). */ +function hasVisibleService(values: SdlBuilderFormValuesType): boolean { + return values.services.some(service => !isLogCollectorService(service)); } /** @@ -130,9 +148,10 @@ function getImportErrorMessage(error: unknown): string { return "The deployment couldn't be loaded."; } -/** Picks the first user-visible service to focus when the screen first mounts. */ -function seedSelectedServiceId(values: SdlBuilderFormValuesType): string | null { - return values.services.find(service => !isLogCollectorService(service))?.id ?? null; +/** Picks the first user-visible service to focus when the screen first mounts. `getInitialState` guarantees one exists. */ +function seedSelectedServiceId(values: SdlBuilderFormValuesType): string { + const visible = values.services.find(candidate => !isLogCollectorService(candidate)) ?? values.services[0]; + return visible.id; } /** Regenerates the preview SDL, keeping the last good output while the form is mid-edit. */ @@ -144,11 +163,29 @@ function regenerateSdl(values: SdlBuilderFormValuesType, previous: string): stri } } +/** + * Resolves the placement the marketplace is scoped to. There is always a placement and a service, so this + * returns a name rather than null: it uses the selected service when present, otherwise the first visible + * service (which also covers the brief window after a removal, before the reselect effect runs), and falls + * back to the first placement. + */ +function resolveSelectedPlacementName( + services: SdlBuilderFormValuesType["services"], + placements: SdlBuilderFormValuesType["placements"], + selectedServiceId: string +): string { + const selected = services.find(candidate => candidate.id === selectedServiceId); + const service = selected ?? services.find(candidate => !isLogCollectorService(candidate)); + const placement = (service && placements.find(candidate => candidate.id === service.placementId)) || placements[0]; + return placement.name; +} + /** Keeps the selection on an existing service, falling back to the first visible one after a removal. */ -function nextSelectedServiceId(values: SdlBuilderFormValuesType, previous: string | null): string | null { +function nextSelectedServiceId(values: SdlBuilderFormValuesType, previous: string): string { const services = values.services ?? []; - if (previous && services.some(service => service?.id === previous)) { + if (services.some(candidate => candidate?.id === previous)) { return previous; } - return services.find(service => service && !isLogCollectorService(service as ServiceType))?.id ?? null; + const visible = services.find(candidate => candidate && !isLogCollectorService(candidate as ServiceType)) ?? services[0]; + return visible.id; } diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.spec.tsx index c3d056574a..2c65fc0557 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.spec.tsx @@ -97,12 +97,19 @@ describe("ConfigureDeploymentPanes", () => { expect(ConfigurationPane).toHaveBeenCalledWith(expect.objectContaining({ selectedServiceId: "svc-1" }), expect.anything()); }); + it("threads the sdl and selected placement into the marketplace pane", () => { + const { MarketplacePane } = setup({ sdl: 'version: "2.0"', selectedPlacementName: "dcloud" }); + + expect(MarketplacePane).toHaveBeenCalledWith(expect.objectContaining({ sdl: 'version: "2.0"', placementName: "dcloud" }), expect.anything()); + }); + function setup( input: { isSdlPreviewEnabled?: boolean; sdl?: string; preserveStorage?: boolean; - selectedServiceId?: string | null; + selectedServiceId?: string; + selectedPlacementName?: string; onSelectService?: (serviceId: string) => void; } = {} ) { @@ -114,10 +121,11 @@ describe("ConfigureDeploymentPanes", () => { )); const DeploymentPane = vi.fn(() =>
); const ConfigurationPane = vi.fn(() =>
); + const MarketplacePane = vi.fn(() =>
); const dependencies: typeof DEPENDENCIES = { DeploymentPane: DeploymentPane as never, ConfigurationPane: ConfigurationPane as never, - MarketplacePane: vi.fn(() =>
), + MarketplacePane: MarketplacePane as never, SdlPreviewPane: SdlPreviewPane as never, useFlag: (() => input.isSdlPreviewEnabled ?? false) as never }; @@ -130,13 +138,14 @@ describe("ConfigureDeploymentPanes", () => { ); - return { SdlPreviewPane, DeploymentPane, ConfigurationPane, unmount }; + return { SdlPreviewPane, DeploymentPane, ConfigurationPane, MarketplacePane, unmount }; } }); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.tsx index 4eb015d17d..81ed545a48 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/ConfigureDeploymentPanes/ConfigureDeploymentPanes.tsx @@ -16,12 +16,13 @@ export const DEPENDENCIES = { DeploymentPane, ConfigurationPane, MarketplacePane type Props = { sdl: string; - selectedServiceId: string | null; + selectedServiceId: string; + selectedPlacementName: string; onSelectService: (serviceId: string) => void; dependencies?: typeof DEPENDENCIES; }; -export const ConfigureDeploymentPanes: FC = ({ sdl, selectedServiceId, onSelectService, dependencies: d = DEPENDENCIES }) => { +export const ConfigureDeploymentPanes: FC = ({ sdl, selectedServiceId, selectedPlacementName, onSelectService, dependencies: d = DEPENDENCIES }) => { const [activePane, setActivePane] = useState("deployment"); const [isSdlPreviewOpen, setIsSdlPreviewOpen] = useAtom(sdlStore.sdlPreviewOpen); const isSdlPreviewEnabled = d.useFlag("ui_sdl_preview_panel"); @@ -36,7 +37,7 @@ export const ConfigureDeploymentPanes: FC = ({ sdl, selectedServiceId, on
- +
{isSdlPreviewEnabled && ( setIsSdlPreviewOpen(true)} onClose={() => setIsSdlPreviewOpen(false)} /> diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.spec.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.spec.tsx index 3efdf5112d..295e3094d9 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.spec.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.spec.tsx @@ -96,7 +96,7 @@ describe("DeploymentPane", () => { render( @@ -119,8 +119,8 @@ describe("DeploymentPane placement management", () => { const values = getForm().getValues(); expect(values.placements).toHaveLength(1); expect(values.placements[0].name).toBe("placement-1"); - expect(values.services).toHaveLength(2); - expect(values.services.map(service => service.title)).toEqual(["service-2", "service-3"]); + expect(values.services).toHaveLength(3); + expect(values.services.map(service => service.title)).toEqual(["service-2", "service-3", "service-4"]); expect(values.services.every(service => service.placementId === values.placements[0].id)).toBe(true); }); @@ -142,7 +142,7 @@ describe("DeploymentPane placement management", () => { render( - + ); diff --git a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.tsx b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.tsx index d71820f9c9..78f14d3dd0 100644 --- a/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.tsx +++ b/apps/deploy-web/src/components/deployments/ConfigureDeployment/DeploymentPane/DeploymentPane.tsx @@ -10,7 +10,7 @@ import { usePlacementManager } from "./usePlacementManager/usePlacementManager"; export const DEPENDENCIES = { PlacementCard, usePlacementManager, IpEndpointsSection }; type Props = { - selectedServiceId: string | null; + selectedServiceId: string; onSelectService: (serviceId: string) => void; dependencies?: typeof DEPENDENCIES; }; @@ -70,7 +70,7 @@ export const DeploymentPane: FC = ({ selectedServiceId, onSelectService, )} @@ -74,7 +98,7 @@ export const PlacementCard: FC = ({ + ); +} diff --git a/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx b/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx new file mode 100644 index 0000000000..12e5268bd6 --- /dev/null +++ b/apps/deploy-web/src/queries/useScreenedProviders.spec.tsx @@ -0,0 +1,111 @@ +import type { UseQueryResult } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { mock } from "vitest-mock-extended"; + +import { AUDITOR } from "@src/utils/deploymentData/v1beta3"; +import { setupQuery } from "../../tests/unit/query-client"; +import type { ScreenedProvider, ScreenedProvidersResponse } from "./useScreenedProviders"; +import { buildPlacementScreeningRequest, useScreenedProviders } from "./useScreenedProviders"; + +const HELLO_WORLD_SDL = `--- +version: "2.0" +services: + web: + image: nginx + expose: + - port: 80 + as: 80 + to: + - global: true +profiles: + compute: + web: + resources: + cpu: + units: 0.1 + memory: + size: 512Mi + storage: + size: 1Gi + placement: + dcloud: + pricing: + web: + denom: uakt + amount: 1000 +deployment: + web: + dcloud: + profile: web + count: 1 +`; + +describe("useScreenedProviders", () => { + it("screens the given placement's group spec, audited-only", () => { + const { useQuery } = setup({ placementName: "dcloud" }); + + expect(useQuery).toHaveBeenCalledWith( + expect.objectContaining({ + name: "dcloud", + requirements: { signedBy: { allOf: [AUDITOR] }, attributes: [] }, + resources: expect.arrayContaining([expect.objectContaining({ count: 1 })]) + }) + ); + }); + + it("falls back to the full audited catalog (empty resources) when the SDL is invalid", () => { + const { useQuery } = setup({ sdl: "foo: [unclosed", placementName: "dcloud" }); + + expect(useQuery).toHaveBeenCalledWith({ + name: "screening", + requirements: { signedBy: { allOf: [AUDITOR] }, attributes: [] }, + resources: [] + }); + }); + + it("returns the screened providers from the query result", () => { + const providers = [makeProvider()]; + const { result } = setup({ placementName: "dcloud", providers }); + + expect(result.current.providers).toEqual(providers); + }); + + function makeProvider(): ScreenedProvider { + return { owner: "akash1a", hostUri: "https://a.example:8443", isAudited: true, location: "us-west", createdAt: "2026-01-01T00:00:00.000Z" }; + } + + function setup(input: { placementName: string; sdl?: string; providers?: ScreenedProvider[] }) { + const useQuery = vi.fn().mockReturnValue( + mock>({ + data: { providers: input.providers ?? [] }, + isLoading: false, + isError: false + }) + ); + const api = { v1: { screenProviders: { useQuery } } } as unknown as ReturnType< + NonNullable[1]>["services"]>["api"]> + >; + + const { result } = setupQuery(() => useScreenedProviders({ sdl: input.sdl ?? HELLO_WORLD_SDL, placementName: input.placementName }), { + services: { api: () => api } + }); + return { result, useQuery }; + } +}); + +describe("buildPlacementScreeningRequest", () => { + it("builds an audited request from the matching placement group spec", () => { + const request = buildPlacementScreeningRequest(HELLO_WORLD_SDL, "dcloud"); + + expect(request).toMatchObject({ name: "dcloud", requirements: { signedBy: { allOf: [AUDITOR] } } }); + expect(request?.resources[0].resource.cpu.units.val).toBeTruthy(); + }); + + it("returns null when the placement is not in the SDL", () => { + expect(buildPlacementScreeningRequest(HELLO_WORLD_SDL, "missing")).toBeNull(); + }); + + it("returns null when the SDL is invalid", () => { + expect(buildPlacementScreeningRequest("foo: [unclosed", "dcloud")).toBeNull(); + }); +}); diff --git a/apps/deploy-web/src/queries/useScreenedProviders.ts b/apps/deploy-web/src/queries/useScreenedProviders.ts new file mode 100644 index 0000000000..1d1b0e2b7c --- /dev/null +++ b/apps/deploy-web/src/queries/useScreenedProviders.ts @@ -0,0 +1,89 @@ +import { useMemo } from "react"; +import { GroupSpec } from "@akashnetwork/chain-sdk/private-types/akash.v1beta4"; +import type { paths } from "@akashnetwork/console-api-types"; + +import { useServices } from "@src/context/ServicesProvider"; +import { DeploymentGroups } from "@src/utils/deploymentData/helpers"; +import { AUDITOR } from "@src/utils/deploymentData/v1beta3"; + +type ScreeningRequest = NonNullable["content"]["application/json"]; + +export type ScreenedProvidersResponse = paths["/v1/bid-screening"]["post"]["responses"][200]["content"]["application/json"]; + +export type ScreenedProvider = ScreenedProvidersResponse["providers"][number]; + +interface UseScreenedProvidersInput { + sdl: string; + placementName: string; +} + +interface UseScreenedProvidersResult { + providers: ScreenedProvider[]; + isLoading: boolean; + isError: boolean; +} + +/** Group name required by the request but irrelevant to catalog matching when no placement resolves. */ +const SCREENING_GROUP_NAME = "screening"; + +/** + * Full audited catalog query, used before a deployment is configured (no SDL yet, mid-edit/invalid SDL, + * or no placement selected). The screening endpoint returns every audited provider for an empty resource spec. + */ +const EMPTY_CATALOG_REQUEST: ScreeningRequest = { + name: SCREENING_GROUP_NAME, + requirements: { signedBy: { allOf: [AUDITOR] }, attributes: [] }, + resources: [] +}; + +/** + * Screens providers for the given placement's group spec. The marketplace is placement-scoped: it converts + * the current SDL to group specs and queries the one matching `placementName`. While the SDL is mid-edit or + * invalid it falls back to the full audited catalog (empty resource spec). Audited-only via signedBy. + */ +export function useScreenedProviders({ sdl, placementName }: UseScreenedProvidersInput): UseScreenedProvidersResult { + const { api } = useServices(); + const request = useMemo(() => buildPlacementScreeningRequest(sdl, placementName) ?? EMPTY_CATALOG_REQUEST, [sdl, placementName]); + const query = api.v1.screenProviders.useQuery(request); + + return { + providers: query.data?.providers ?? [], + isLoading: query.isLoading, + isError: query.isError + }; +} + +/** + * Converts the current SDL into a screening request for a single placement's group spec. Returns null + * when the SDL is incomplete/invalid (e.g. mid-edit) or the placement isn't in it yet, so the caller can + * fall back to the full catalog. `signedBy` forces audited-only screening; `attributes` are passed through + * from the placement and carry the `location-region` filter (and any other declared attribute). The proto + * JSON encodes resource values as base64 integer strings, which the screening endpoint accepts. + */ +export function buildPlacementScreeningRequest(sdl: string, placementName: string): ScreeningRequest | null { + if (!sdl) return null; + + let groups: ReturnType; + try { + groups = DeploymentGroups(sdl); + } catch { + return null; + } + + const group = groups.find(candidate => candidate.name === placementName); + if (!group) return null; + + const groupJson = GroupSpec.toJSON(group) as { + resources: ScreeningRequest["resources"]; + requirements?: { attributes?: Array<{ key: string; value: string }> }; + }; + + return { + name: placementName, + requirements: { + signedBy: { allOf: [AUDITOR] }, + attributes: groupJson.requirements?.attributes ?? [] + }, + resources: groupJson.resources + }; +} diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts index 694846232f..ec53fe1b2f 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.spec.ts @@ -5,6 +5,7 @@ import { EndpointSchema, SdlBuilderFormValuesSchema, ServiceSchema } from "./sdl describe("ServiceSchema", () => { it("validates a minimal valid service", () => { const result = ServiceSchema.safeParse({ + id: "svc-1", title: "web", image: "nginx:latest", profile: { @@ -29,6 +30,7 @@ describe("SdlBuilderFormValuesSchema", () => { placements: [{ id: "p-1", name: "dcloud" }], services: [ { + id: "svc-1", title: "web", image: "nginx:latest", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, @@ -55,6 +57,7 @@ describe("SdlBuilderFormValuesSchema", () => { ], services: [ { + id: "svc-1", title: "web", image: "nginx:latest", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, @@ -74,6 +77,7 @@ describe("SdlBuilderFormValuesSchema", () => { it("rejects duplicate service titles", () => { const service = { + id: "svc-1", title: "web", image: "nginx:latest", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, @@ -84,7 +88,7 @@ describe("SdlBuilderFormValuesSchema", () => { }; const result = SdlBuilderFormValuesSchema.safeParse({ placements: [{ id: "p-1", name: "dcloud" }], - services: [service, { ...service }] + services: [service, { ...service, id: "svc-2" }] }); expect(result.success).toBe(false); @@ -98,6 +102,7 @@ describe("SdlBuilderFormValuesSchema", () => { placements: [{ id: "p-1", name: "dcloud" }], services: [ { + id: "svc-1", title: "web", image: "nginx:latest", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, @@ -118,6 +123,7 @@ describe("SdlBuilderFormValuesSchema", () => { placements: [{ id: "p-1", name: "dcloud" }], services: [ { + id: "svc-1", title: "web", image: "nginx:latest", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, @@ -144,6 +150,7 @@ describe("SdlBuilderFormValuesSchema", () => { placements: [{ id: "p-1", name: "dcloud" }], services: [ { + id: "svc-1", title: "web", image: "", profile: { cpu: 0.1, ram: 256, ramUnit: "Mi", storage: [{ size: 512, unit: "Mi" }] }, diff --git a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts index 92465da649..60520539e5 100644 --- a/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts +++ b/apps/deploy-web/src/types/sdlBuilder/sdlBuilder.ts @@ -342,7 +342,7 @@ const validateStorageAmount = (value: number, storageUnit: string, serviceCount: export const ServiceSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1, { message: "Service id is required." }), title: z .string() .min(1, { message: "Service name is required." }) diff --git a/packages/console-api-types/src/operations.gen.ts b/packages/console-api-types/src/operations.gen.ts index 9cca1228eb..d591a94295 100644 --- a/packages/console-api-types/src/operations.gen.ts +++ b/packages/console-api-types/src/operations.gen.ts @@ -19,6 +19,7 @@ export const operations = { createLease: { path: "/v1/leases", method: "post", operationId: "createLease", pathParams: [], queryParams: [], hasBody: true }, listApiKeys: { path: "/v1/api-keys", method: "get", operationId: "listApiKeys", pathParams: [], queryParams: [], hasBody: false }, listBids: { path: "/v1/bids", method: "get", operationId: "listBids", pathParams: [], queryParams: ["dseq"], hasBody: false }, + screenProviders: { path: "/v1/bid-screening", method: "post", operationId: "screenProviders", pathParams: [], queryParams: [], hasBody: true }, listGpuPrices: { path: "/v1/gpu-prices", method: "get", operationId: "listGpuPrices", pathParams: [], queryParams: [], hasBody: false }, createAlert: { path: "/v1/alerts", method: "post", operationId: "createAlert", pathParams: [], queryParams: [], hasBody: true }, listAlerts: { diff --git a/packages/console-api-types/src/schema.d.ts b/packages/console-api-types/src/schema.d.ts index f1775c73ca..b7417f4669 100644 --- a/packages/console-api-types/src/schema.d.ts +++ b/packages/console-api-types/src/schema.d.ts @@ -6966,6 +6966,11 @@ export interface paths { * @example 2026-01-01T00:00:00.000Z */ createdAt: string; + /** + * @description Provider region from the location-region attribute (signed preferred, else self-declared); null if unset + * @example us-west + */ + location: string | null; }[]; }; }; diff --git a/packages/dev-config/.eslintrc.base.js b/packages/dev-config/.eslintrc.base.js index 96c698c145..f3a8e95718 100644 --- a/packages/dev-config/.eslintrc.base.js +++ b/packages/dev-config/.eslintrc.base.js @@ -39,7 +39,7 @@ module.exports = { "error", { additionalVerbs: { - post: { collection: ["deposit"] }, + post: { collection: ["deposit", "screen"] }, delete: { single: ["close"] } } }