Support updating docker image for a server from the frontend
This commit is contained in:
parent
1dacd703df
commit
5bbb36b3cf
12 changed files with 171 additions and 19 deletions
|
@ -2,13 +2,16 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Pterodactyl\Models\Server;
|
use Pterodactyl\Models\Server;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Pterodactyl\Repositories\Eloquent\ServerRepository;
|
use Pterodactyl\Repositories\Eloquent\ServerRepository;
|
||||||
use Pterodactyl\Services\Servers\ReinstallServerService;
|
use Pterodactyl\Services\Servers\ReinstallServerService;
|
||||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
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\RenameServerRequest;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
|
||||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
|
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
|
||||||
|
|
||||||
class SettingsController extends ClientApiController
|
class SettingsController extends ClientApiController
|
||||||
|
@ -73,4 +76,26 @@ class SettingsController extends ClientApiController
|
||||||
|
|
||||||
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,6 +62,7 @@ class StartupController extends ClientApiController
|
||||||
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
||||||
->addMeta([
|
->addMeta([
|
||||||
'startup_command' => $startup,
|
'startup_command' => $startup,
|
||||||
|
'docker_images' => $server->egg->docker_images,
|
||||||
'raw_startup_command' => $server->startup,
|
'raw_startup_command' => $server->startup,
|
||||||
])
|
])
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
|
||||||
|
|
||||||
|
use Webmozart\Assert\Assert;
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
|
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
|
|
||||||
|
class SetDockerImageRequest extends ClientApiRequest implements ClientPermissionsRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function permission(): string
|
||||||
|
{
|
||||||
|
return Permission::ACTION_STARTUP_DOCKER_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array[]
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var \Pterodactyl\Models\Server $server */
|
||||||
|
$server = $this->route()->parameter('server');
|
||||||
|
|
||||||
|
Assert::isInstanceOf($server, Server::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,6 +58,7 @@ class Permission extends Model
|
||||||
|
|
||||||
const ACTION_STARTUP_READ = 'startup.read';
|
const ACTION_STARTUP_READ = 'startup.read';
|
||||||
const ACTION_STARTUP_UPDATE = 'startup.update';
|
const ACTION_STARTUP_UPDATE = 'startup.update';
|
||||||
|
const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
|
||||||
|
|
||||||
const ACTION_SETTINGS_RENAME = 'settings.rename';
|
const ACTION_SETTINGS_RENAME = 'settings.rename';
|
||||||
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
||||||
|
@ -176,6 +177,7 @@ class Permission extends Model
|
||||||
'keys' => [
|
'keys' => [
|
||||||
'read' => 'Allows a user to view the startup variables for a server.',
|
'read' => 'Allows a user to view the startup variables for a server.',
|
||||||
'update' => 'Allows a user to modify the startup variables for the 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.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,7 @@ class ServerTransformer extends BaseClientTransformer
|
||||||
'cpu' => $server->cpu,
|
'cpu' => $server->cpu,
|
||||||
],
|
],
|
||||||
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
|
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
|
||||||
|
'docker_image' => $server->image,
|
||||||
'egg_features' => $server->egg->inherit_features,
|
'egg_features' => $server->egg->inherit_features,
|
||||||
'feature_limits' => [
|
'feature_limits' => [
|
||||||
'databases' => $server->database_limit,
|
'databases' => $server->database_limit,
|
||||||
|
|
|
@ -22,6 +22,7 @@ export interface Server {
|
||||||
port: number;
|
port: number;
|
||||||
};
|
};
|
||||||
invocation: string;
|
invocation: string;
|
||||||
|
dockerImage: string;
|
||||||
description: string;
|
description: string;
|
||||||
limits: {
|
limits: {
|
||||||
memory: number;
|
memory: number;
|
||||||
|
@ -50,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
||||||
name: data.name,
|
name: data.name,
|
||||||
node: data.node,
|
node: data.node,
|
||||||
invocation: data.invocation,
|
invocation: data.invocation,
|
||||||
|
dockerImage: data.docker_image,
|
||||||
sftpDetails: {
|
sftpDetails: {
|
||||||
ip: data.sftp_details.ip,
|
ip: data.sftp_details.ip,
|
||||||
port: data.sftp_details.port,
|
port: data.sftp_details.port,
|
||||||
|
|
5
resources/scripts/api/server/setSelectedDockerImage.ts
Normal file
5
resources/scripts/api/server/setSelectedDockerImage.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default async (uuid: string, image: string): Promise<void> => {
|
||||||
|
await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image });
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import { ServerEggVariable } from '@/api/server/types';
|
||||||
interface Response {
|
interface Response {
|
||||||
invocation: string;
|
invocation: string;
|
||||||
variables: ServerEggVariable[];
|
variables: ServerEggVariable[];
|
||||||
|
dockerImages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
|
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
|
||||||
|
@ -13,5 +14,5 @@ export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startu
|
||||||
|
|
||||||
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
|
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 });
|
}, { initialData, errorRetryCount: 3 });
|
||||||
|
|
|
@ -2,16 +2,28 @@ import React from 'react';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
import Fade from '@/components/elements/Fade';
|
import Fade from '@/components/elements/Fade';
|
||||||
import tw from 'twin.macro';
|
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 }) => (
|
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
|
||||||
<div css={tw`relative`}>
|
<Container visible={visible}>
|
||||||
<Fade appear unmountOnExit in={visible} timeout={150}>
|
<Fade appear unmountOnExit in={visible} timeout={150}>
|
||||||
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
|
||||||
<Spinner size={'small'}/>
|
<Spinner size={'small'}/>
|
||||||
</div>
|
</div>
|
||||||
</Fade>
|
</Fade>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default InputSpinner;
|
export default InputSpinner;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import VariableBox from '@/components/server/startup/VariableBox';
|
import VariableBox from '@/components/server/startup/VariableBox';
|
||||||
|
@ -9,15 +9,32 @@ import ServerError from '@/components/screens/ServerError';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
|
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 StartupContainer = () => {
|
||||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
const [ loading, setLoading ] = useState(false);
|
||||||
const invocation = ServerContext.useStoreState(state => state.server.data!.invocation);
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const variables = ServerContext.useStoreState(state => state.server.data!.variables);
|
|
||||||
|
|
||||||
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 setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||||
|
const isCustomImage = data && !data.dockerImages.map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Since we're passing in initial data this will not trigger on mount automatically. We
|
// Since we're passing in initial data this will not trigger on mount automatically. We
|
||||||
|
@ -36,6 +53,20 @@ const StartupContainer = () => {
|
||||||
}));
|
}));
|
||||||
}, [ data ]);
|
}, [ data ]);
|
||||||
|
|
||||||
|
const updateSelectedDockerImage = useCallback((v: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
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 (
|
return (
|
||||||
!data ?
|
!data ?
|
||||||
(!error || (error && isValidating)) ?
|
(!error || (error && isValidating)) ?
|
||||||
|
@ -47,15 +78,49 @@ const StartupContainer = () => {
|
||||||
onRetry={() => mutate()}
|
onRetry={() => mutate()}
|
||||||
/>
|
/>
|
||||||
:
|
:
|
||||||
<ServerContentBlock title={'Startup Settings'}>
|
<ServerContentBlock title={'Startup Settings'} showFlashKey={'startup:image'}>
|
||||||
<TitledGreyBox title={'Startup Command'}>
|
<div css={tw`flex`}>
|
||||||
|
<TitledGreyBox title={'Startup Command'} css={tw`flex-1`}>
|
||||||
<div css={tw`px-1 py-2`}>
|
<div css={tw`px-1 py-2`}>
|
||||||
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
|
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
|
||||||
{data.invocation}
|
{data.invocation}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TitledGreyBox>
|
</TitledGreyBox>
|
||||||
<div css={tw`grid gap-8 md:grid-cols-2 mt-10`}>
|
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 ml-10`}>
|
||||||
|
{data.dockerImages.length > 1 && !isCustomImage ?
|
||||||
|
<>
|
||||||
|
<InputSpinner visible={loading}>
|
||||||
|
<Select
|
||||||
|
disabled={data.dockerImages.length < 2}
|
||||||
|
onChange={updateSelectedDockerImage}
|
||||||
|
defaultValue={variables.dockerImage}
|
||||||
|
>
|
||||||
|
{data.dockerImages.map(image => (
|
||||||
|
<option key={image} value={image}>{image}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</InputSpinner>
|
||||||
|
<p css={tw`text-xs text-neutral-300 mt-2`}>
|
||||||
|
This is an advanced feature allowing you to select a Docker image to use when
|
||||||
|
running this server instance.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
<Input disabled readOnly value={variables.dockerImage}/>
|
||||||
|
{isCustomImage &&
|
||||||
|
<p css={tw`text-xs text-neutral-300 mt-2`}>
|
||||||
|
This {'server\'s'} Docker image has been manually set by an administrator and cannot
|
||||||
|
be changed through this UI.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</TitledGreyBox>
|
||||||
|
</div>
|
||||||
|
<h3 css={tw`mt-8 mb-2 text-2xl`}>Variables</h3>
|
||||||
|
<div css={tw`grid gap-8 md:grid-cols-2`}>
|
||||||
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
|
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
|
||||||
</div>
|
</div>
|
||||||
</ServerContentBlock>
|
</ServerContentBlock>
|
||||||
|
|
|
@ -32,8 +32,9 @@ const VariableBox = ({ variable }: Props) => {
|
||||||
|
|
||||||
updateStartupVariable(uuid, variable.envVariable, value)
|
updateStartupVariable(uuid, variable.envVariable, value)
|
||||||
.then(([ response, invocation ]) => mutate(data => ({
|
.then(([ response, invocation ]) => mutate(data => ({
|
||||||
|
...data,
|
||||||
invocation,
|
invocation,
|
||||||
variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v),
|
variables: (data.variables || []).map(v => v.envVariable === response.envVariable ? response : v),
|
||||||
}), false))
|
}), false))
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -67,7 +68,7 @@ const VariableBox = ({ variable }: Props) => {
|
||||||
placeholder={variable.defaultValue}
|
placeholder={variable.defaultValue}
|
||||||
/>
|
/>
|
||||||
</InputSpinner>
|
</InputSpinner>
|
||||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
<p css={tw`mt-1 text-xs text-neutral-300`}>
|
||||||
{variable.description}
|
{variable.description}
|
||||||
</p>
|
</p>
|
||||||
</TitledGreyBox>
|
</TitledGreyBox>
|
||||||
|
|
|
@ -114,5 +114,6 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||||
Route::group(['prefix' => '/settings'], function () {
|
Route::group(['prefix' => '/settings'], function () {
|
||||||
Route::post('/rename', 'Servers\SettingsController@rename');
|
Route::post('/rename', 'Servers\SettingsController@rename');
|
||||||
Route::post('/reinstall', 'Servers\SettingsController@reinstall');
|
Route::post('/reinstall', 'Servers\SettingsController@reinstall');
|
||||||
|
Route::put('/docker-image', 'Servers\SettingsController@dockerImage');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue