diff --git a/.i18n-tracker.lock b/.i18n-tracker.lock index c0280ad0..fc8ca60b 100644 --- a/.i18n-tracker.lock +++ b/.i18n-tracker.lock @@ -232,7 +232,7 @@ "src/domains/model-registry/hooks/use-model-registry-form.tsx": "7209947c2079b41b6b83c9051a5235e2", "src/domains/image-registry/lib/transform-image-registry-values.ts": "8c26cafe14bf2883c1146e93f64ff9b4", "src/domains/image-registry/hooks/use-image-registry-form.tsx": "ca7795582e54f820a3fb26492a68c13c", - "src/domains/cluster/hooks/use-cluster-form.tsx": "5d4ca2e20acacb990501aed14cb69d75", + "src/domains/cluster/hooks/use-cluster-form.tsx": "83f339daf54ba7be2b7c07c78808d88e", "src/foundation/types/resource-types.ts": "6fecb3bc5fdd96313e1745a5defcfd87", "src/foundation/hooks/use-variables-input.ts": "4e7645dd01994e5fd252bc3e2ad7676f", "src/foundation/components/VariablesInput.tsx": "24057e155ee2272d6ba949b42639d752", @@ -274,7 +274,7 @@ "src/domains/external-endpoint/lib/find-overlapping-model-keys.ts": "c95ecffb5e8e3e0a45983f6e1f5d2ea4", "src/pages/auth/lib/validate-password-match.ts": "c113f0c5136ce533297e38cc20bf698f", "src/domains/endpoint/lib/pause-action.ts": "3efa178458d6906c0cc81dad7c41e675", - "src/domains/cluster/components/ClusterUpgradeAction.tsx": "69bbf15ed8d1b0e68a235ada096065e9", + "src/domains/cluster/components/ClusterUpgradeAction.tsx": "12d36c4aa2e5ac304e0a038dfc59087d", "src/foundation/components/OemFavicon.tsx": "bb7648f90f369cb8a26439963f1966d1", "src/domains/external-endpoint/lib/get-endpoint-type.ts": "a5fec42612881e3206ee2e19ca3c820f", "src/domains/endpoint/hooks/use-document-list.ts": "7dc70792a61cc83271e61844abc90ec8", @@ -282,8 +282,8 @@ "src/domains/endpoint/components/DocumentListEditor.tsx": "397a5b14b2689352e678e44863b4e2f0", "src/domains/endpoint/components/PlaygroundLayout.tsx": "c4940cfc57dc4696b166ac65f5c769cb", "src/domains/cluster/components/ClusterUpgradeTip.tsx": "fd41e3d18ecd41c1efda9dc1d06da083", - "src/pages/dashboard/QuickStartDialog.tsx": "b22e8651e26203a5c62ceb9d249f8ef1", - "src/foundation/hooks/use-quick-start.ts": "04189be863e68f0769b650db5a60b985", + "src/pages/dashboard/QuickStartDialog.tsx": "5211801c3c58b8c9755b0bf71a279d30", + "src/foundation/hooks/use-quick-start.ts": "4a8e61679c598598782a5de445a4e987", "src/foundation/components/ResourceResultList.tsx": "5b87054f8939e64a98ac67e886b68c64", "src/foundation/hooks/use-refine-field-array.ts": "7dbaa5d3a0ea2c6db1c09da0863f1b74", "src/domains/external-endpoint/components/TestConnectivityButton.tsx": "c4c97cc62ad41f004d08238c569e0054", @@ -374,5 +374,6 @@ "src/foundation/lib/api/model-registries.ts": "63aabfb75c7df819933df76333e6f67b", "src/foundation/lib/model-registry-visibility.ts": "24550d054dd3e6365f09c526b829302a", "src/domains/role/lib/format-permission.ts": "9f2b7f7a698e51dc6ba8188a808db44b", - "src/domains/model-catalog/lib/catalog-import.ts": "0722a4c305e6f4066c9f70aed2e61ed0" + "src/domains/model-catalog/lib/catalog-import.ts": "0722a4c305e6f4066c9f70aed2e61ed0", + "src/foundation/lib/api/available-cluster-versions.ts": "db03a13dc9debc8220e03be899b8d96e" } diff --git a/src/domains/cluster/components/ClusterUpgradeAction.test.tsx b/src/domains/cluster/components/ClusterUpgradeAction.test.tsx index e07e334d..1ebc18c6 100644 --- a/src/domains/cluster/components/ClusterUpgradeAction.test.tsx +++ b/src/domains/cluster/components/ClusterUpgradeAction.test.tsx @@ -7,13 +7,11 @@ const mocks = vi.hoisted(() => ({ availableVersions: [] as string[], invalidate: vi.fn(), mutateAsync: vi.fn(), + useCustom: vi.fn(), })); vi.mock("@refinedev/core", () => ({ - useCustom: () => ({ - data: { data: { available_versions: mocks.availableVersions } }, - isLoading: false, - }), + useCustom: mocks.useCustom, useInvalidate: () => mocks.invalidate, useUpdate: () => ({ mutateAsync: mocks.mutateAsync, isLoading: false }), })); @@ -127,6 +125,27 @@ describe("ClusterUpgradeAction", () => { mocks.availableVersions = []; mocks.invalidate.mockReset().mockResolvedValue(undefined); mocks.mutateAsync.mockReset().mockResolvedValue({}); + mocks.useCustom.mockImplementation(() => ({ + data: { data: { available_versions: mocks.availableVersions } }, + isLoading: false, + })); + mocks.useCustom.mockClear(); + }); + + it("requests versions for the cluster type when the dialog opens", async () => { + renderUpgradeAction(); + + fireEvent.click( + screen.getByRole("button", { name: "clusters.actions.upgrade" }), + ); + + await screen.findByRole("dialog"); + expect(mocks.useCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "/clusters/available_versions?workspace=default&image_registry=registry-a&cluster_type=ssh", + queryOptions: { enabled: true }, + }), + ); }); it("only offers newer versions and selects the highest result", async () => { diff --git a/src/domains/cluster/components/ClusterUpgradeAction.tsx b/src/domains/cluster/components/ClusterUpgradeAction.tsx index 95f63b94..bf8067e9 100644 --- a/src/domains/cluster/components/ClusterUpgradeAction.tsx +++ b/src/domains/cluster/components/ClusterUpgradeAction.tsx @@ -25,6 +25,7 @@ import { isUpgradeVersion, } from "@/domains/cluster/lib/upgrade-versions"; import type { Cluster } from "@/domains/cluster/types"; +import { buildAvailableClusterVersionsURL } from "@/foundation/lib/api/available-cluster-versions"; import { useTranslation } from "@/foundation/lib/i18n"; type AvailableVersionsResponse = { @@ -106,24 +107,17 @@ function UpgradeDialog({ const { mutateAsync, isLoading: isUpdating } = useUpdate(); const [targetVersion, setTargetVersion] = useState(""); - const upgradeVersionsUrl = (() => { - const params = new URLSearchParams({ - workspace: cluster.metadata.workspace ?? "", - image_registry: cluster.spec.image_registry, - cluster_type: cluster.spec.type, - }); - if (cluster.status?.accelerator_type) { - params.set("accelerator_type", cluster.status.accelerator_type); - } - return `/clusters/available_versions?${params.toString()}`; - })(); - + const upgradeVersionsUrl = buildAvailableClusterVersionsURL( + cluster.spec.type, + cluster.metadata.workspace, + cluster.spec.image_registry, + ); const { data, isLoading: isLoadingVersions } = useCustom({ - url: upgradeVersionsUrl, + url: upgradeVersionsUrl ?? "", method: "get", queryOptions: { - enabled: open, + enabled: open && !!upgradeVersionsUrl, }, }); diff --git a/src/domains/cluster/hooks/use-cluster-form.test.tsx b/src/domains/cluster/hooks/use-cluster-form.test.tsx index 7f165d3b..6f02bd96 100644 --- a/src/domains/cluster/hooks/use-cluster-form.test.tsx +++ b/src/domains/cluster/hooks/use-cluster-form.test.tsx @@ -39,14 +39,14 @@ vi.mock("@refinedev/react-hook-form", async () => { }; }); +const { mockUseCustom } = vi.hoisted(() => ({ mockUseCustom: vi.fn() })); +let mockAvailableVersions: string[] = []; + vi.mock("@refinedev/core", () => ({ useSelect: () => ({ query: { data: { data: [] }, isLoading: false }, }), - useCustom: () => ({ - data: { data: { available_versions: [] } }, - isLoading: false, - }), + useCustom: mockUseCustom, })); vi.mock("@/foundation/components/WorkspaceField", () => ({ @@ -104,6 +104,7 @@ function ClusterConfigurationForm() { const { form, clusterConfigurationFields } = useClusterForm({ action: "create", }); + formInstance = form; return (
{clusterConfigurationFields}
@@ -157,6 +158,12 @@ describe("useClusterForm", () => { beforeEach(() => { vi.unstubAllEnvs(); formInstance = null; + mockAvailableVersions = []; + mockUseCustom.mockImplementation(() => ({ + data: { data: { available_versions: mockAvailableVersions } }, + isLoading: false, + })); + mockUseCustom.mockClear(); }); it("groups type, image registry and version under cluster configuration", () => { @@ -170,6 +177,119 @@ describe("useClusterForm", () => { expect(screen.getByText("common.fields.version")).toBeTruthy(); }); + it("does not preselect a version from available_versions", async () => { + mockAvailableVersions = ["v1.1.0", "v1.2.0"]; + + render(); + + await waitFor(() => { + expect(formInstance?.getValues("spec.version")).toBeFalsy(); + }); + }); + + it("waits for an image registry before querying available versions", async () => { + render(); + + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "", + queryOptions: { enabled: false }, + }), + ); + + act(() => formInstance?.setValue("spec.image_registry", "registry-a")); + + await waitFor(() => { + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "/clusters/available_versions?workspace=default&image_registry=registry-a&cluster_type=ssh", + queryOptions: { enabled: true }, + }), + ); + }); + }); + + it("updates the version query when the cluster type changes", async () => { + render(); + + act(() => formInstance?.setValue("spec.image_registry", "registry-a")); + selectType("clusters.options.kubernetes"); + + await waitFor(() => { + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "/clusters/available_versions?workspace=default&image_registry=registry-a&cluster_type=kubernetes", + queryOptions: { enabled: true }, + }), + ); + }); + }); + + it("clears a selected version when the registry no longer provides it", async () => { + mockAvailableVersions = ["v1.1.0"]; + render(); + + act(() => formInstance?.setValue("spec.image_registry", "registry-a")); + await waitFor(() => { + expect(formInstance?.getValues("spec.version")).toBeFalsy(); + }); + + act(() => { + formInstance?.setValue("spec.version", "v1.1.0"); + }); + expect(formInstance?.getValues("spec.version")).toBe("v1.1.0"); + mockAvailableVersions = ["v1.2.0"]; + + act(() => formInstance?.setValue("spec.image_registry", "registry-b")); + + await waitFor(() => { + expect(formInstance?.getValues("spec.version")).toBeFalsy(); + }); + }); + + it("clears a selected version when the cluster type changes", async () => { + mockAvailableVersions = ["v1.1.0"]; + render(); + + act(() => formInstance?.setValue("spec.image_registry", "registry-a")); + act(() => formInstance?.setValue("spec.version", "v1.1.0")); + expect(formInstance?.getValues("spec.version")).toBe("v1.1.0"); + + selectType("clusters.options.kubernetes"); + + await waitFor(() => { + expect(formInstance?.getValues("spec.version")).toBeFalsy(); + }); + }); + + it("clears a selected version when the current source refreshes without it", async () => { + mockAvailableVersions = ["v1.1.0"]; + const view = render(); + + act(() => formInstance?.setValue("spec.image_registry", "registry-a")); + act(() => formInstance?.setValue("spec.version", "v1.1.0")); + expect(formInstance?.getValues("spec.version")).toBe("v1.1.0"); + + mockAvailableVersions = []; + view.rerender(); + + await waitFor(() => { + expect(formInstance?.getValues("spec.version")).toBeFalsy(); + }); + }); + + it("does not query versions while workspace is not routable", () => { + mockUseWorkspace.mockReturnValue({ current: "_all_" }); + render(); + + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: "", + queryOptions: { enabled: false }, + }), + ); + }); + describe("edit mode — NodeIPsField props", () => { it("passes headIpDisabled=true without disabled in edit mode", () => { nodeIPsFieldProps.mockClear(); @@ -230,7 +350,6 @@ describe("useClusterForm", () => { }); it("does not seed router version when switching to kubernetes", async () => { - vi.stubEnv("VITE_DEFAULT_CLUSTER_VERSION", "v1.0.1"); render(); selectType("clusters.options.kubernetes"); diff --git a/src/domains/cluster/hooks/use-cluster-form.tsx b/src/domains/cluster/hooks/use-cluster-form.tsx index 63f9e48f..d9616b83 100644 --- a/src/domains/cluster/hooks/use-cluster-form.tsx +++ b/src/domains/cluster/hooks/use-cluster-form.tsx @@ -1,6 +1,6 @@ import { useCustom, useSelect } from "@refinedev/core"; import { useForm } from "@refinedev/react-hook-form"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; @@ -19,6 +19,7 @@ import { isValidWorkspace, useWorkspace, } from "@/foundation/hooks/use-workspace"; +import { buildAvailableClusterVersionsURL } from "@/foundation/lib/api/available-cluster-versions"; export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { const { t } = useTranslation(); @@ -57,7 +58,6 @@ export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { }, model_caches: [], }, - version: import.meta.env.VITE_DEFAULT_CLUSTER_VERSION, }, }, }); @@ -78,6 +78,7 @@ export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { const workspace = form.watch("metadata.workspace"); const type = form.watch("spec.type"); + const imageRegistry = form.watch("spec.image_registry"); const isKubernetes = type === "kubernetes"; const isSSH = type === "ssh"; const isModelCacheDisabled = isEdit && isSSH; @@ -89,16 +90,17 @@ export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { meta, }); - const imageRegistry = form.watch("spec.image_registry"); - - const versionsQueryEnabled = !!workspace && !!imageRegistry && !!type; + const availableVersionsUrl = buildAvailableClusterVersionsURL( + type, + workspace, + imageRegistry, + ); + const versionsQueryEnabled = !!availableVersionsUrl; const { data: versionsData, isLoading: isLoadingVersions } = useCustom<{ available_versions: string[]; }>({ - url: versionsQueryEnabled - ? `/clusters/available_versions?${new URLSearchParams({ workspace, image_registry: imageRegistry, cluster_type: type }).toString()}` - : "", + url: availableVersionsUrl ?? "", method: "get", queryOptions: { enabled: versionsQueryEnabled, @@ -108,11 +110,41 @@ export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { const availableVersions = versionsData?.data?.available_versions ?? []; const specVersion = form.watch("spec.version"); + const previousVersionSource = useRef(availableVersionsUrl); + + useEffect(() => { + const versionSourceChanged = + previousVersionSource.current !== availableVersionsUrl; + previousVersionSource.current = availableVersionsUrl; + + if ( + !isEdit && + specVersion && + (versionSourceChanged || + (!isLoadingVersions && + versionsQueryEnabled && + !availableVersions.includes(specVersion))) + ) { + form.setValue("spec.version", undefined, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, [ + availableVersions, + availableVersionsUrl, + form, + isEdit, + isLoadingVersions, + specVersion, + versionsQueryEnabled, + ]); + const acceleratorVirtualizationSupported = isKubernetes && isAcceleratorVirtualizationSupported(specVersion); - // In edit mode, ensure the current version appears in the options list - // even if the API doesn't return it (it only returns upgrade targets). + // In edit mode, retain the persisted version for display when it is no + // longer creatable by the current control plane. const versionOptions = (() => { if (isEdit && specVersion && !availableVersions.includes(specVersion)) { return [specVersion, ...availableVersions]; @@ -120,30 +152,6 @@ export const useClusterForm = ({ action }: { action: "create" | "edit" }) => { return availableVersions; })(); - // Sync spec.version with available versions in create mode: - // - No versions available: clear spec.version - // - Version not set or not in list: select latest - // Skip in edit mode — the form already has the cluster's existing spec.version. - useEffect(() => { - if (isEdit) return; - const currentVersion = form.getValues("spec.version"); - if (availableVersions.length === 0) { - if (currentVersion) - form.setValue("spec.version", "", { - shouldValidate: false, - shouldDirty: false, - }); - return; - } - if (!currentVersion || !availableVersions.includes(currentVersion)) { - form.setValue( - "spec.version", - availableVersions[availableVersions.length - 1], - { shouldValidate: false, shouldDirty: false }, - ); - } - }, [isEdit, availableVersions, form]); - useEffect(() => { if ( isKubernetes && diff --git a/src/foundation/hooks/use-quick-start.test.ts b/src/foundation/hooks/use-quick-start.test.ts index bd7371e4..a98355d3 100644 --- a/src/foundation/hooks/use-quick-start.test.ts +++ b/src/foundation/hooks/use-quick-start.test.ts @@ -1,10 +1,12 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; // --- Module mocks --- const mockMutateAsync = vi.fn(); const mockGetOne = vi.fn(); +const mockCreate = vi.fn(); +const mockUseCustom = vi.fn(); const defaultEnginesData = [ { @@ -17,16 +19,30 @@ const defaultEnginesData = [ }, ]; let mockEnginesData = defaultEnginesData; +let mockAvailableVersions = ["v1.2.0"]; +let mockDefaultClusterVersion: string | null = "v1.2.0"; vi.mock("@refinedev/core", () => ({ useCreate: () => ({ mutateAsync: mockMutateAsync }), - useDataProvider: () => () => ({ getOne: mockGetOne }), + useDataProvider: () => () => ({ + getOne: mockGetOne, + create: mockCreate, + }), useSelect: () => ({ query: { data: { data: mockEnginesData }, isLoading: false, }, }), + useCustom: mockUseCustom.mockImplementation(() => ({ + data: { + data: { + available_versions: mockAvailableVersions, + default_cluster_version: mockDefaultClusterVersion, + }, + }, + isLoading: false, + })), })); let mockCurrentWorkspace: string = "test-ws"; @@ -45,9 +61,18 @@ const TEST_INPUT = { "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", }; -async function quickStartHook() { +async function quickStartHook( + options?: { versionsEnabled?: boolean }, + waitForVersions = true, +) { const { useQuickStart } = await import("./use-quick-start"); - return renderHook(() => useQuickStart()); + const hook = renderHook(() => useQuickStart(options)); + if (waitForVersions) { + await waitFor(() => + expect(hook.result.current.isLoadingVersions).toBe(false), + ); + } + return hook; } // --- Setup --- @@ -55,9 +80,13 @@ async function quickStartHook() { beforeEach(() => { vi.clearAllMocks(); mockEnginesData = defaultEnginesData; + mockAvailableVersions = ["v1.2.0"]; + mockDefaultClusterVersion = "v1.2.0"; mockCurrentWorkspace = "test-ws"; mockGetOne.mockRejectedValue(new Error("not found")); + mockCreate.mockResolvedValue({ data: {} }); mockMutateAsync.mockResolvedValue({ data: {} }); + mockUseCustom.mockClear(); }); // --- Tests --- @@ -87,13 +116,108 @@ describe("useQuickStart", () => { }); }); + it("exposes the API default cluster version", async () => { + const { result } = await quickStartHook(); + + expect(result.current.defaultClusterVersion).toBe("v1.2.0"); + await waitFor(() => { + expect(result.current.isDefaultClusterVersionAvailable).toBe(true); + }); + }); + + it("does not expose the default version before the registry is ready", async () => { + let resolveLookup = (_result: { data: { id: number } }) => {}; + mockGetOne.mockImplementationOnce( + () => + new Promise<{ data: { id: number } }>((resolve) => { + resolveLookup = resolve; + }), + ); + + const { result } = await quickStartHook(undefined, false); + + expect(result.current.isLoadingVersions).toBe(true); + expect(result.current.isDefaultClusterVersionAvailable).toBe(false); + + resolveLookup({ data: { id: 1 } }); + await waitFor(() => expect(result.current.isLoadingVersions).toBe(false)); + }); + + it("prepares public-docker before querying available versions", async () => { + const { result } = await quickStartHook(); + + await waitFor(() => expect(result.current.isLoadingVersions).toBe(false)); + + expect(mockGetOne).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "image_registries", + id: "public-docker", + }), + ); + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "image_registries", + variables: expect.objectContaining({ + metadata: expect.objectContaining({ name: "public-docker" }), + }), + }), + ); + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryOptions: { enabled: true }, + }), + ); + }); + + it("creates public-docker when a lookup returns no data", async () => { + mockGetOne.mockResolvedValueOnce({ data: undefined }); + + const { result } = await quickStartHook(); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ resource: "image_registries" }), + ); + expect(result.current.imageRegistryError).toBeNull(); + }); + }); + + it("rechecks public-docker after a concurrent create", async () => { + mockGetOne + .mockRejectedValueOnce({ statusCode: 404 }) + .mockResolvedValueOnce({ data: { id: 1 } }); + mockCreate.mockRejectedValueOnce({ statusCode: 409 }); + + const { result } = await quickStartHook(); + + await waitFor(() => { + expect(result.current.imageRegistryError).toBeNull(); + expect(mockUseCustom).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryOptions: { enabled: true }, + }), + ); + }); + }); + + it("uses the SSH release versions endpoint and respects dialog enablement", async () => { + await quickStartHook({ versionsEnabled: false }); + + expect(mockUseCustom).toHaveBeenCalledWith( + expect.objectContaining({ + url: "/clusters/available_versions?workspace=test-ws&image_registry=public-docker&cluster_type=ssh", + queryOptions: { enabled: false }, + }), + ); + }); + describe("execute", () => { it("creates all 4 resources sequentially when none exist", async () => { const { result } = await quickStartHook(); await act(() => result.current.execute(TEST_INPUT)); - expect(mockGetOne).toHaveBeenCalledTimes(4); + expect(mockGetOne).toHaveBeenCalledTimes(5); expect(mockMutateAsync).toHaveBeenCalledTimes(4); expect(result.current.state.phase).toBe("done"); for (const step of result.current.state.steps) { @@ -195,9 +319,60 @@ describe("useQuickStart", () => { expect(sshConfig.auth.ssh_user).toBe("root"); }); + it("uses the API default cluster version", async () => { + const { result } = await quickStartHook(); + + await act(() => result.current.execute(TEST_INPUT)); + + const clusterCall = mockMutateAsync.mock.calls.find( + (c) => c[0].resource === "clusters", + ); + expect(clusterCall?.[0].values.spec.version).toBe("v1.2.0"); + }); + + it("does not create a cluster when the API has no default version", async () => { + mockDefaultClusterVersion = null; + const { result } = await quickStartHook(); + + await act(() => result.current.execute(TEST_INPUT)); + + expect( + mockMutateAsync.mock.calls.some( + (call) => call[0].resource === "clusters", + ), + ).toBe(false); + expect( + mockMutateAsync.mock.calls.some( + (call) => call[0].resource === "endpoints", + ), + ).toBe(false); + expect(result.current.state.phase).toBe("error"); + expect(result.current.state.error).toBe( + "default_cluster_version_unavailable", + ); + }); + + it("does not create a cluster when the API default is not available", async () => { + mockAvailableVersions = []; + const { result } = await quickStartHook(); + + await act(() => result.current.execute(TEST_INPUT)); + + expect( + mockMutateAsync.mock.calls.some( + (call) => call[0].resource === "clusters", + ), + ).toBe(false); + expect(result.current.state.phase).toBe("error"); + expect(result.current.state.error).toBe( + "default_cluster_version_unavailable", + ); + }); + it("skips existing resources", async () => { // image_registries and model_registries exist mockGetOne + .mockResolvedValueOnce({ data: { id: 0 } }) // Quick Start registry preflight .mockResolvedValueOnce({ data: { id: 1 } }) // image_registries exists .mockResolvedValueOnce({ data: { id: 2 } }) // model_registries exists .mockRejectedValueOnce(new Error("not found")) // clusters doesn't diff --git a/src/foundation/hooks/use-quick-start.ts b/src/foundation/hooks/use-quick-start.ts index a12692a5..5796341a 100644 --- a/src/foundation/hooks/use-quick-start.ts +++ b/src/foundation/hooks/use-quick-start.ts @@ -1,6 +1,12 @@ -import { useCreate, useDataProvider, useSelect } from "@refinedev/core"; -import { useCallback, useMemo, useState } from "react"; +import { + useCreate, + useCustom, + useDataProvider, + useSelect, +} from "@refinedev/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ALL_WORKSPACES, useWorkspace } from "@/foundation/hooks/use-workspace"; +import { buildAvailableClusterVersionsURL } from "@/foundation/lib/api/available-cluster-versions"; import type { Metadata } from "@/foundation/types/basic-types"; /** Inline type to avoid L1→L2 dependency on endpoint types */ @@ -30,6 +36,11 @@ type QuickStartPhase = "input" | "creating" | "done" | "error"; interface QuickStartState { phase: QuickStartPhase; steps: QuickStartStep[]; + error?: string; +} + +interface QuickStartOptions { + versionsEnabled?: boolean; } const INITIAL_STEPS: QuickStartStep[] = [ @@ -86,7 +97,11 @@ function buildModelRegistryValues(workspace: string) { }; } -function buildClusterValues(workspace: string, input: QuickStartInput) { +function buildClusterValues( + workspace: string, + input: QuickStartInput, + clusterVersion: string, +) { let sshPrivateKey = input.sshPrivateKey; if (!sshPrivateKey.endsWith("\n")) { sshPrivateKey += "\n"; @@ -100,7 +115,7 @@ function buildClusterValues(workspace: string, input: QuickStartInput) { spec: { type: "ssh", image_registry: "public-docker", - version: import.meta.env.VITE_DEFAULT_CLUSTER_VERSION || "v1.0.0", + version: clusterVersion, config: { ssh_config: { provider: { head_ip: input.headIp, worker_ips: [] }, @@ -134,10 +149,41 @@ function buildEndpointValues(workspace: string, engineVersion: string) { }; } -export function useQuickStart() { +function isNotFoundError(error: unknown): boolean { + if (error instanceof Error && /not found/i.test(error.message)) { + return true; + } + + if (typeof error !== "object" || error === null) return false; + + const candidate = error as { + code?: string | number; + message?: string; + statusCode?: number; + }; + return ( + candidate.statusCode === 404 || + String(candidate.code) === "404" || + /not found/i.test(candidate.message ?? "") + ); +} + +function getErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + return String(error.message); + } + + return "Unknown error"; +} + +export function useQuickStart({ + versionsEnabled = true, +}: QuickStartOptions = {}) { const { current: currentWorkspace } = useWorkspace(); const { mutateAsync: createResource } = useCreate(); const dataProvider = useDataProvider(); + const dataProviderRef = useRef(dataProvider); + dataProviderRef.current = dataProvider; // `currentWorkspace` may be the `_all_` sentinel when no workspace is // selected (fresh session with empty localStorage). That value is invalid @@ -147,6 +193,13 @@ export function useQuickStart() { ? currentWorkspace : "default"; + const [isImageRegistryReady, setIsImageRegistryReady] = useState(false); + const [isPreparingImageRegistry, setIsPreparingImageRegistry] = + useState(false); + const [imageRegistryError, setImageRegistryError] = useState( + null, + ); + const engines = useSelect({ resource: "engines", meta: { @@ -156,6 +209,108 @@ export function useQuickStart() { }, }); + const availableVersionsUrl = buildAvailableClusterVersionsURL( + "ssh", + workspace, + "public-docker", + ); + + // The availability endpoint validates the selected registry. Quick Start + // owns its public registry, so establish it before asking for versions. + useEffect(() => { + let cancelled = false; + + if (!versionsEnabled) { + setIsImageRegistryReady(false); + setIsPreparingImageRegistry(false); + setImageRegistryError(null); + return () => { + cancelled = true; + }; + } + + setIsImageRegistryReady(false); + setIsPreparingImageRegistry(true); + setImageRegistryError(null); + + const meta = { + idColumnName: "metadata->name", + workspace, + workspaced: true, + }; + + void (async () => { + try { + const provider = dataProviderRef.current(); + let imageRegistryExists = false; + try { + const result = await provider.getOne({ + resource: "image_registries", + id: "public-docker", + meta, + }); + imageRegistryExists = !!result.data; + } catch (error) { + if (!isNotFoundError(error)) throw error; + } + + if (!imageRegistryExists) { + try { + await provider.create({ + resource: "image_registries", + variables: buildImageRegistryValues(workspace), + meta, + }); + } catch (createError) { + // Another Quick Start may have created the registry between the + // initial read and this create. Confirm before treating it as an + // availability failure. + try { + const result = await provider.getOne({ + resource: "image_registries", + id: "public-docker", + meta, + }); + if (!result.data) throw createError; + } catch { + throw createError; + } + } + } + + if (!cancelled) setIsImageRegistryReady(true); + } catch (error) { + if (!cancelled) setImageRegistryError(getErrorMessage(error)); + } finally { + if (!cancelled) setIsPreparingImageRegistry(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [versionsEnabled, workspace]); + + const { data: versionsData, isLoading: isLoadingVersions } = useCustom<{ + available_versions: string[]; + default_cluster_version: string | null; + }>({ + url: availableVersionsUrl ?? "", + method: "get", + queryOptions: { + enabled: + versionsEnabled && isImageRegistryReady && !!availableVersionsUrl, + }, + }); + const availableVersions = versionsData?.data?.available_versions ?? []; + const defaultClusterVersion = + versionsData?.data?.default_cluster_version ?? null; + const clusterVersion = defaultClusterVersion ?? ""; + const isDefaultClusterVersionAvailable = + isImageRegistryReady && + !!clusterVersion && + availableVersions.includes(clusterVersion); + const llamaCppVersion = useMemo(() => { const llamaCpp = engines.query.data?.data?.find( (e) => e.metadata.name === "llama-cpp", @@ -194,7 +349,7 @@ export function useQuickStart() { const execute = useCallback( async (input: QuickStartInput) => { const steps = INITIAL_STEPS.map((s) => ({ ...s })); - setState({ phase: "creating", steps }); + setState({ phase: "creating", steps, error: undefined }); const createMeta = { idColumnName: "metadata->name", @@ -205,7 +360,6 @@ export function useQuickStart() { const valueBuilders: Record Record> = { "image-registry": () => buildImageRegistryValues(workspace), "model-registry": () => buildModelRegistryValues(workspace), - cluster: () => buildClusterValues(workspace, input), endpoint: () => buildEndpointValues(workspace, llamaCppVersion), }; @@ -214,6 +368,20 @@ export function useQuickStart() { step.status = "in-progress"; setState({ phase: "creating", steps: [...steps] }); + if ( + step.id === "cluster" && + (!clusterVersion || !isDefaultClusterVersionAvailable) + ) { + step.status = "error"; + step.error = "default_cluster_version_unavailable"; + setState({ + phase: "error", + steps: [...steps], + error: "default_cluster_version_unavailable", + }); + return; + } + try { const exists = await checkResourceExists( step.resourceTable, @@ -223,20 +391,24 @@ export function useQuickStart() { if (exists) { step.status = "skipped"; } else { - const values = valueBuilders[step.id](); - await createResource({ - resource: step.resourceTable, - values, - meta: createMeta, - }); + if (step.id === "cluster") { + await createResource({ + resource: step.resourceTable, + values: buildClusterValues(workspace, input, clusterVersion), + meta: createMeta, + }); + } else { + await createResource({ + resource: step.resourceTable, + values: valueBuilders[step.id](), + meta: createMeta, + }); + } step.status = "success"; } } catch (error) { step.status = "error"; - step.error = - typeof error === "object" && error !== null && "message" in error - ? String(error.message) - : "Unknown error"; + step.error = getErrorMessage(error); setState({ phase: "error", steps: [...steps] }); return; } @@ -246,7 +418,14 @@ export function useQuickStart() { setState({ phase: "done", steps: [...steps] }); }, - [workspace, llamaCppVersion, checkResourceExists, createResource], + [ + workspace, + llamaCppVersion, + clusterVersion, + isDefaultClusterVersionAvailable, + checkResourceExists, + createResource, + ], ); const reset = useCallback(() => { @@ -261,5 +440,12 @@ export function useQuickStart() { execute, reset, isEnginesLoading: engines.query.isLoading, + defaultClusterVersion, + isDefaultClusterVersionAvailable, + isLoadingVersions: + isLoadingVersions || + isPreparingImageRegistry || + (versionsEnabled && !isImageRegistryReady && !imageRegistryError), + imageRegistryError, }; } diff --git a/src/foundation/lib/api/available-cluster-versions.test.ts b/src/foundation/lib/api/available-cluster-versions.test.ts new file mode 100644 index 00000000..a7fa9e96 --- /dev/null +++ b/src/foundation/lib/api/available-cluster-versions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildAvailableClusterVersionsURL } from "./available-cluster-versions"; + +describe("buildAvailableClusterVersionsURL", () => { + it.each(["ssh", "kubernetes"])( + "includes the target registry for %s profiles", + (clusterType) => { + expect( + buildAvailableClusterVersionsURL( + clusterType, + "default", + "public-docker", + ), + ).toBe( + `/clusters/available_versions?workspace=default&image_registry=public-docker&cluster_type=${clusterType}`, + ); + }, + ); + + it.each([ + [undefined, "default", "public-docker"], + ["ssh", undefined, "public-docker"], + ["ssh", "_all_", "public-docker"], + ["ssh", "default", undefined], + ["ssh", "default", ""], + ["managed", "default", "public-docker"], + ["SSH", "default", "public-docker"], + ])( + "does not create a request for an unsupported cluster type: %s", + (clusterType, workspace, imageRegistry) => { + expect( + buildAvailableClusterVersionsURL(clusterType, workspace, imageRegistry), + ).toBeUndefined(); + }, + ); + + it("encodes workspace and registry names", () => { + expect( + buildAvailableClusterVersionsURL("ssh", "team one", "private/reg"), + ).toBe( + "/clusters/available_versions?workspace=team+one&image_registry=private%2Freg&cluster_type=ssh", + ); + }); +}); diff --git a/src/foundation/lib/api/available-cluster-versions.ts b/src/foundation/lib/api/available-cluster-versions.ts new file mode 100644 index 00000000..ce323f4a --- /dev/null +++ b/src/foundation/lib/api/available-cluster-versions.ts @@ -0,0 +1,27 @@ +export function buildAvailableClusterVersionsURL( + clusterType: string | null | undefined, + workspace: string | null | undefined, + imageRegistry: string | null | undefined, +): string | undefined { + if (clusterType !== "ssh" && clusterType !== "kubernetes") { + return undefined; + } + + const normalizedWorkspace = workspace?.trim(); + const normalizedImageRegistry = imageRegistry?.trim(); + if ( + !normalizedWorkspace || + normalizedWorkspace === "_all_" || + !normalizedImageRegistry + ) { + return undefined; + } + + const params = new URLSearchParams({ + workspace: normalizedWorkspace, + image_registry: normalizedImageRegistry, + cluster_type: clusterType, + }); + + return `/clusters/available_versions?${params.toString()}`; +} diff --git a/src/locales/en-US.json b/src/locales/en-US.json index e9a414d6..aaa2eab2 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -355,7 +355,9 @@ "messages": { "creating": "Setting up your infrastructure...", "success": "All resources created. Cluster provisioning may take several minutes. You can check progress on the Cluster detail page, and then the Endpoint detail page once the cluster is ready.", - "error": "Something went wrong. You can retry — already created resources will be skipped." + "error": "Something went wrong. You can retry — already created resources will be skipped.", + "defaultUnavailable": "The default cluster version is not available in the selected registry.", + "registryUnavailable": "The public image registry could not be prepared." }, "validation": { "invalidIp": "Please enter a valid IP address", diff --git a/src/pages/dashboard/QuickStartDialog.tsx b/src/pages/dashboard/QuickStartDialog.tsx index a82c4f73..a253441b 100644 --- a/src/pages/dashboard/QuickStartDialog.tsx +++ b/src/pages/dashboard/QuickStartDialog.tsx @@ -31,7 +31,14 @@ export function QuickStartDialog({ onOpenChange, }: QuickStartDialogProps) { const { t } = useTranslation(); - const { state, execute, isEnginesLoading } = useQuickStart(); + const { + state, + execute, + isEnginesLoading, + isDefaultClusterVersionAvailable, + isLoadingVersions, + imageRegistryError, + } = useQuickStart({ versionsEnabled: open }); const [headIp, setHeadIp] = useState(""); const [sshUser, setSshUser] = useState(""); @@ -67,7 +74,13 @@ export function QuickStartDialog({ }; const handleDeploy = () => { - if (!validate()) return; + if ( + !isDefaultClusterVersionAvailable || + imageRegistryError || + !validate() + ) { + return; + } execute({ headIp: headIp.trim(), sshUser: sshUser.trim(), @@ -175,9 +188,27 @@ export function QuickStartDialog({ )} + {!isLoadingVersions && imageRegistryError && ( +

+ {t("quick_start.messages.registryUnavailable")} +

+ )} + {!isLoadingVersions && + !imageRegistryError && + !isDefaultClusterVersionAvailable && ( +

+ {t("quick_start.messages.defaultUnavailable")} +

+ )} +