diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d58dbc7d38..144a21bc86 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -99,6 +99,7 @@ export const API = { // Fleets FLEETS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/list`, FLEETS_DETAILS: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/get`, + FLEETS_APPLY: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/apply`, FLEETS_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/delete`, FLEET_INSTANCES_DELETE: (projectName: IProject['project_name']) => `${API.BASE()}/project/${projectName}/fleets/delete_instances`, diff --git a/frontend/src/components/ButtonWithConfirmation/index.tsx b/frontend/src/components/ButtonWithConfirmation/index.tsx index 78c2793d9c..56ae78ad59 100644 --- a/frontend/src/components/ButtonWithConfirmation/index.tsx +++ b/frontend/src/components/ButtonWithConfirmation/index.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import Box from '@cloudscape-design/components/box'; import { Button } from '../Button'; @@ -13,20 +14,31 @@ export const ButtonWithConfirmation: React.FC = ({ confirmButtonLabel, ...props }) => { + const { t } = useTranslation(); const [showDeleteConfirm, setShowConfirmDelete] = useState(false); const toggleDeleteConfirm = () => { setShowConfirmDelete((val) => !val); }; - const content = typeof confirmContent === 'string' ? {confirmContent} : confirmContent; - const onConfirm = () => { if (onClick) onClick(); setShowConfirmDelete(false); }; + const getContent = () => { + if (!confirmContent) { + return {t('confirm_dialog.message')}; + } + + if (typeof confirmContent === 'string') { + return {confirmContent}; + } + + return confirmContent; + }; + return ( <> } + {isAvailableProjectManaging && } ); }; @@ -137,7 +137,7 @@ export const ProjectList: React.FC = () => { {t('common.delete')} - + } diff --git a/frontend/src/pages/Project/constants.tsx b/frontend/src/pages/Project/constants.tsx new file mode 100644 index 0000000000..151740116b --- /dev/null +++ b/frontend/src/pages/Project/constants.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +export const DEFAULT_FLEET_INFO = { + header:

Default fleet

, + body: ( + <> +

+ Fleets act both as pools of instances and as templates for how those instances are provisioned. When you submit + a dev environment, task, or service, dstack reuses idle instances or provisions new + ones based on the fleet configuration. +

+ +

+ If you set Min number of instances to 0, dstack will provision instances + only when you run a dev environment, task, or service. +

+ +

+ At least one fleet is required to run dev environments, tasks, or services. Create it here, or create it using + the dstack apply command via the CLI. +

+ +

+ To learn more about fleets, see the{' '} + + documentation + + . +

+ + ), +}; diff --git a/frontend/src/pages/Project/hooks/useYupValidationResolver.ts b/frontend/src/pages/Project/hooks/useYupValidationResolver.ts new file mode 100644 index 0000000000..2cd694c63d --- /dev/null +++ b/frontend/src/pages/Project/hooks/useYupValidationResolver.ts @@ -0,0 +1,38 @@ +import { useCallback } from 'react'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +export function useYupValidationResolver(validationSchema) { + return useCallback( + async (data: TData) => { + try { + const values = await validationSchema.validate(data, { + abortEarly: false, + }); + + return { + values, + errors: {}, + }; + } catch (errors) { + return { + values: {}, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + errors: errors.inner.reduce( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + (allErrors, currentError) => ({ + ...allErrors, + [currentError.path]: { + type: currentError.type ?? 'validation', + message: currentError.message, + }, + }), + {}, + ), + }; + } + }, + [validationSchema], + ); +} diff --git a/frontend/src/pages/User/Details/index.tsx b/frontend/src/pages/User/Details/index.tsx index 805b2efc98..1ee131094c 100644 --- a/frontend/src/pages/User/Details/index.tsx +++ b/frontend/src/pages/User/Details/index.tsx @@ -95,7 +95,13 @@ export const UserDetails: React.FC = () => { - + {t('confirm_dialog.message')}} + onDiscard={toggleDeleteConfirm} + onConfirm={deleteUserHandler} + confirmButtonLabel={t('common.delete')} + /> ); }; diff --git a/frontend/src/pages/User/List/index.tsx b/frontend/src/pages/User/List/index.tsx index 17bc417e94..5831d03383 100644 --- a/frontend/src/pages/User/List/index.tsx +++ b/frontend/src/pages/User/List/index.tsx @@ -208,8 +208,10 @@ export const UserList: React.FC = () => { {t('confirm_dialog.message')}} onDiscard={toggleDeleteConfirm} onConfirm={deleteSelectedUserHandler} + confirmButtonLabel={t('common.delete')} /> ); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index fbdeca2942..34a8abaaf0 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -10,7 +10,7 @@ import { LoginByGoogleCallback } from 'App/Login/LoginByGoogleCallback'; import { LoginByOktaCallback } from 'App/Login/LoginByOktaCallback'; import { TokenLogin } from 'App/Login/TokenLogin'; import { Logout } from 'App/Logout'; -import { FleetDetails, FleetList } from 'pages/Fleets'; +import { FleetAdd, FleetDetails, FleetList } from 'pages/Fleets'; import { EventsList as FleetEventsList } from 'pages/Fleets/Details/Events'; import { FleetDetails as FleetDetailsGeneral } from 'pages/Fleets/Details/FleetDetails'; import { FleetInspect } from 'pages/Fleets/Details/Inspect'; @@ -202,6 +202,10 @@ export const router = createBrowserRouter([ path: ROUTES.FLEETS.LIST, element: , }, + { + path: ROUTES.FLEETS.ADD.TEMPLATE, + element: , + }, { path: ROUTES.FLEETS.DETAILS.TEMPLATE, element: , diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index fea2f978a4..288cef72fc 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -137,6 +137,10 @@ export const ROUTES = { FLEETS: { LIST: '/fleets', + ADD: { + TEMPLATE: `/projects/:projectName/fleets/add`, + FORMAT: (projectName: string) => buildRoute(ROUTES.FLEETS.ADD.TEMPLATE, { projectName }), + }, DETAILS: { TEMPLATE: `/projects/:projectName/fleets/:fleetId`, FORMAT: (projectName: string, fleetId: string) => diff --git a/frontend/src/services/fleet.ts b/frontend/src/services/fleet.ts index 3405a18b8b..fa723d7d2d 100644 --- a/frontend/src/services/fleet.ts +++ b/frontend/src/services/fleet.ts @@ -66,7 +66,25 @@ export const fleetApi = createApi({ invalidatesTags: ['Fleets'], }), + + applyFleet: builder.mutation({ + query: ({ projectName, ...body }) => { + return { + url: API.PROJECTS.FLEETS_APPLY(projectName), + method: 'POST', + body, + }; + }, + + invalidatesTags: ['Fleets'], + }), }), }); -export const { useGetFleetsQuery, useLazyGetFleetsQuery, useDeleteFleetMutation, useGetFleetDetailsQuery } = fleetApi; +export const { + useGetFleetsQuery, + useLazyGetFleetsQuery, + useDeleteFleetMutation, + useGetFleetDetailsQuery, + useApplyFleetMutation, +} = fleetApi; diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 2f0a4bd6b5..8875c48df6 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -74,7 +74,7 @@ export const projectApi = createApi({ providesTags: (result) => (result ? [{ type: 'Projects' as const, id: result.project_name }] : []), }), - createProject: builder.mutation({ + createProject: builder.mutation({ query: (project) => ({ url: API.PROJECTS.CREATE(), method: 'POST', diff --git a/frontend/src/store.ts b/frontend/src/store.ts index ca19b1206d..03d2c820e7 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1,5 +1,6 @@ import { configureStore } from '@reduxjs/toolkit'; +import confirmationReducer from 'components/ConfirmationDialog/slice'; import notificationsReducer from 'components/Notifications/slice'; import { artifactApi } from 'services/artifact'; @@ -25,6 +26,7 @@ export const store = configureStore({ reducer: { app: appReducer, notifications: notificationsReducer, + confirmation: confirmationReducer, [projectApi.reducerPath]: projectApi.reducer, [runApi.reducerPath]: runApi.reducer, [artifactApi.reducerPath]: artifactApi.reducer, diff --git a/frontend/src/types/fleet.d.ts b/frontend/src/types/fleet.d.ts index 892acf41fa..2813cd4023 100644 --- a/frontend/src/types/fleet.d.ts +++ b/frontend/src/types/fleet.d.ts @@ -45,9 +45,12 @@ declare interface IFleetConfigurationRequest { max?: number; }; placement?: 'any' | 'cluster'; + reservation?: string; resources?: IFleetConfigurationResource[]; + blocks?: string | number; backends?: TBackendType[]; regions?: string[]; + availability_zones?: string[]; instance_types?: string[]; spot_policy?: TSpotPolicy; retry?: @@ -76,13 +79,14 @@ declare interface IProfileRequest { instance_name?: string; creation_policy?: 'reuse' | 'reuse-or-create'; idle_duration?: number | string; - name: string; + name?: string; default?: boolean; } declare interface IFleetSpec { - autocreated: boolean; + autocreated?: boolean; configuration: IFleetConfigurationRequest; + configuration_path?: string; profile: IProfileRequest; } @@ -96,3 +100,11 @@ declare interface IFleet { status: 'submitted' | 'active' | 'terminating' | 'terminated' | 'failed'; status_message: string; } + +declare interface IApplyFleetPlanRequestRequest { + plan: { + spec: IFleetSpec; + }; + + force: boolean; +} diff --git a/frontend/src/types/project.d.ts b/frontend/src/types/project.d.ts index cf24c84d03..babb4dab7a 100644 --- a/frontend/src/types/project.d.ts +++ b/frontend/src/types/project.d.ts @@ -46,3 +46,7 @@ declare interface IProjectSecret { name: string; value?: string; } + +declare type IProjectCreateRequestParams = Pick & { + is_public: boolean; +};