diff --git a/app/Http/Controllers/Api/Client/Servers/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/SettingsController.php index 7dfbf7b4f..80fc0e181 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/SettingsController.php @@ -2,13 +2,16 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Services\Servers\ReinstallServerService; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; class SettingsController extends ClientApiController @@ -73,4 +76,26 @@ class SettingsController extends ClientApiController return new JsonResponse([], Response::HTTP_ACCEPTED); } + + /** + * Changes the Docker image in use by the server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function dockerImage(SetDockerImageRequest $request, Server $server) + { + if (!in_array($server->image, $server->egg->docker_images)) { + throw new BadRequestHttpException( + 'This server\'s Docker image has been manually set by an administrator and cannot be updated.' + ); + } + + $server->forceFill(['image' => $request->input('docker_image')])->saveOrFail(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } } diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php index e0c580279..7e06abf8e 100644 --- a/app/Http/Controllers/Api/Client/Servers/StartupController.php +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -62,6 +62,7 @@ class StartupController extends ClientApiController ->transformWith($this->getTransformer(EggVariableTransformer::class)) ->addMeta([ 'startup_command' => $startup, + 'docker_images' => $server->egg->docker_images, 'raw_startup_command' => $server->startup, ]) ->toArray(); diff --git a/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php b/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php new file mode 100644 index 000000000..be0b7213a --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php @@ -0,0 +1,36 @@ +route()->parameter('server'); + + Assert::isInstanceOf($server, Server::class); + + return [ + 'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)], + ]; + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 180d844f0..8f6f219c3 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -58,6 +58,7 @@ class Permission extends Model const ACTION_STARTUP_READ = 'startup.read'; const ACTION_STARTUP_UPDATE = 'startup.update'; + const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image'; const ACTION_SETTINGS_RENAME = 'settings.rename'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; @@ -176,6 +177,7 @@ class Permission extends Model 'keys' => [ 'read' => 'Allows a user to view the startup variables for a server.', 'update' => 'Allows a user to modify the startup variables for the server.', + 'docker-image' => 'Allows a user to modify the Docker image used when running the server.', ], ], diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 637772094..9897f8517 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -63,6 +63,7 @@ class ServerTransformer extends BaseClientTransformer 'cpu' => $server->cpu, ], 'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)), + 'docker_image' => $server->image, 'egg_features' => $server->egg->inherit_features, 'feature_limits' => [ 'databases' => $server->database_limit, diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 3521ed0da..d9b76b400 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -22,6 +22,7 @@ export interface Server { port: number; }; invocation: string; + dockerImage: string; description: string; limits: { memory: number; @@ -50,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) name: data.name, node: data.node, invocation: data.invocation, + dockerImage: data.docker_image, sftpDetails: { ip: data.sftp_details.ip, port: data.sftp_details.port, diff --git a/resources/scripts/api/server/setSelectedDockerImage.ts b/resources/scripts/api/server/setSelectedDockerImage.ts new file mode 100644 index 000000000..70042f3a6 --- /dev/null +++ b/resources/scripts/api/server/setSelectedDockerImage.ts @@ -0,0 +1,5 @@ +import http from '@/api/http'; + +export default async (uuid: string, image: string): Promise => { + await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image }); +}; diff --git a/resources/scripts/api/swr/getServerStartup.ts b/resources/scripts/api/swr/getServerStartup.ts index 892f78fdd..b7089b7bd 100644 --- a/resources/scripts/api/swr/getServerStartup.ts +++ b/resources/scripts/api/swr/getServerStartup.ts @@ -6,6 +6,7 @@ import { ServerEggVariable } from '@/api/server/types'; interface Response { invocation: string; variables: ServerEggVariable[]; + dockerImages: string[]; } export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise => { @@ -13,5 +14,5 @@ export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startu const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); - return { invocation: data.meta.startup_command, variables }; + return { invocation: data.meta.startup_command, variables, dockerImages: data.meta.docker_images || [] }; }, { initialData, errorRetryCount: 3 }); diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx index cac920f8a..f26fba621 100644 --- a/resources/scripts/components/elements/InputSpinner.tsx +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -2,16 +2,28 @@ import React from 'react'; import Spinner from '@/components/elements/Spinner'; import Fade from '@/components/elements/Fade'; import tw from 'twin.macro'; +import styled, { css } from 'styled-components/macro'; +import Select from '@/components/elements/Select'; + +const Container = styled.div<{ visible?: boolean }>` + ${tw`relative`}; + + ${props => props.visible && css` + & ${Select} { + background-image: none; + } + `}; +`; const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( -
+
{children} -
+ ); export default InputSpinner; diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx index 4daf6acc4..e0049f3fd 100644 --- a/resources/scripts/components/server/startup/StartupContainer.tsx +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import tw from 'twin.macro'; import VariableBox from '@/components/server/startup/VariableBox'; @@ -9,15 +9,32 @@ import ServerError from '@/components/screens/ServerError'; import { httpErrorToHuman } from '@/api/http'; import { ServerContext } from '@/state/server'; import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect'; +import Select from '@/components/elements/Select'; +import isEqual from 'react-fast-compare'; +import Input from '@/components/elements/Input'; +import setSelectedDockerImage from '@/api/server/setSelectedDockerImage'; +import InputSpinner from '@/components/elements/InputSpinner'; +import useFlash from '@/plugins/useFlash'; const StartupContainer = () => { - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const invocation = ServerContext.useStoreState(state => state.server.data!.invocation); - const variables = ServerContext.useStoreState(state => state.server.data!.variables); + const [ loading, setLoading ] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); - const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables }); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const variables = ServerContext.useStoreState(({ server }) => ({ + variables: server.data!.variables, + invocation: server.data!.invocation, + dockerImage: server.data!.dockerImage, + // @ts-ignore + }), isEqual); + + const { data, error, isValidating, mutate } = getServerStartup(uuid, { + ...variables, + dockerImages: [ variables.dockerImage ], + }); const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); + const isCustomImage = data && !data.dockerImages.map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase()); useEffect(() => { // Since we're passing in initial data this will not trigger on mount automatically. We @@ -36,6 +53,20 @@ const StartupContainer = () => { })); }, [ data ]); + const updateSelectedDockerImage = useCallback((v: React.ChangeEvent) => { + setLoading(true); + clearFlashes('startup:image'); + + const image = v.currentTarget.value; + setSelectedDockerImage(uuid, image) + .then(() => setServerFromState(s => ({ ...s, dockerImage: image }))) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'startup:image', error }); + }) + .then(() => setLoading(false)); + }, [ uuid ]); + return ( !data ? (!error || (error && isValidating)) ? @@ -47,15 +78,49 @@ const StartupContainer = () => { onRetry={() => mutate()} /> : - - -
-

- {data.invocation} -

-
-
-
+ +
+ +
+

+ {data.invocation} +

+
+
+ + {data.dockerImages.length > 1 && !isCustomImage ? + <> + + + +

+ This is an advanced feature allowing you to select a Docker image to use when + running this server instance. +

+ + : + <> + + {isCustomImage && +

+ This {'server\'s'} Docker image has been manually set by an administrator and cannot + be changed through this UI. +

+ } + + } +
+
+

Variables

+
{data.variables.map(variable => )}
diff --git a/resources/scripts/components/server/startup/VariableBox.tsx b/resources/scripts/components/server/startup/VariableBox.tsx index ba1108218..5ebca8f6e 100644 --- a/resources/scripts/components/server/startup/VariableBox.tsx +++ b/resources/scripts/components/server/startup/VariableBox.tsx @@ -32,8 +32,9 @@ const VariableBox = ({ variable }: Props) => { updateStartupVariable(uuid, variable.envVariable, value) .then(([ response, invocation ]) => mutate(data => ({ + ...data, invocation, - variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v), + variables: (data.variables || []).map(v => v.envVariable === response.envVariable ? response : v), }), false)) .catch(error => { console.error(error); @@ -67,7 +68,7 @@ const VariableBox = ({ variable }: Props) => { placeholder={variable.defaultValue} /> -

+

{variable.description}

diff --git a/routes/api-client.php b/routes/api-client.php index bc689f80b..320c8ec55 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -114,5 +114,6 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::group(['prefix' => '/settings'], function () { Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/reinstall', 'Servers\SettingsController@reinstall'); + Route::put('/docker-image', 'Servers\SettingsController@dockerImage'); }); });