Skip to content

Commit 5bbb36b

Browse files
committed
Support updating docker image for a server from the frontend
1 parent 1dacd70 commit 5bbb36b

File tree

12 files changed

+170
-18
lines changed

12 files changed

+170
-18
lines changed

app/Http/Controllers/Api/Client/Servers/SettingsController.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@
22

33
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
44

5+
use Illuminate\Http\Request;
56
use Illuminate\Http\Response;
67
use Pterodactyl\Models\Server;
78
use Illuminate\Http\JsonResponse;
89
use Pterodactyl\Repositories\Eloquent\ServerRepository;
910
use Pterodactyl\Services\Servers\ReinstallServerService;
1011
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
12+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1113
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
14+
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest;
1215
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
1316

1417
class SettingsController extends ClientApiController
@@ -73,4 +76,26 @@ public function reinstall(ReinstallServerRequest $request, Server $server)
7376

7477
return new JsonResponse([], Response::HTTP_ACCEPTED);
7578
}
79+
80+
/**
81+
* Changes the Docker image in use by the server.
82+
*
83+
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest $request
84+
* @param \Pterodactyl\Models\Server $server
85+
* @return \Illuminate\Http\JsonResponse
86+
*
87+
* @throws \Throwable
88+
*/
89+
public function dockerImage(SetDockerImageRequest $request, Server $server)
90+
{
91+
if (!in_array($server->image, $server->egg->docker_images)) {
92+
throw new BadRequestHttpException(
93+
'This server\'s Docker image has been manually set by an administrator and cannot be updated.'
94+
);
95+
}
96+
97+
$server->forceFill(['image' => $request->input('docker_image')])->saveOrFail();
98+
99+
return new JsonResponse([], Response::HTTP_NO_CONTENT);
100+
}
76101
}

app/Http/Controllers/Api/Client/Servers/StartupController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public function index(GetStartupRequest $request, Server $server)
6262
->transformWith($this->getTransformer(EggVariableTransformer::class))
6363
->addMeta([
6464
'startup_command' => $startup,
65+
'docker_images' => $server->egg->docker_images,
6566
'raw_startup_command' => $server->startup,
6667
])
6768
->toArray();
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
4+
5+
use Webmozart\Assert\Assert;
6+
use Pterodactyl\Models\Server;
7+
use Illuminate\Validation\Rule;
8+
use Pterodactyl\Models\Permission;
9+
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
10+
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
11+
12+
class SetDockerImageRequest extends ClientApiRequest implements ClientPermissionsRequest
13+
{
14+
/**
15+
* @return string
16+
*/
17+
public function permission(): string
18+
{
19+
return Permission::ACTION_STARTUP_DOCKER_IMAGE;
20+
}
21+
22+
/**
23+
* @return array[]
24+
*/
25+
public function rules(): array
26+
{
27+
/** @var \Pterodactyl\Models\Server $server */
28+
$server = $this->route()->parameter('server');
29+
30+
Assert::isInstanceOf($server, Server::class);
31+
32+
return [
33+
'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)],
34+
];
35+
}
36+
}

app/Models/Permission.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ class Permission extends Model
5858

5959
const ACTION_STARTUP_READ = 'startup.read';
6060
const ACTION_STARTUP_UPDATE = 'startup.update';
61+
const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image';
6162

6263
const ACTION_SETTINGS_RENAME = 'settings.rename';
6364
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
@@ -176,6 +177,7 @@ class Permission extends Model
176177
'keys' => [
177178
'read' => 'Allows a user to view the startup variables for a server.',
178179
'update' => 'Allows a user to modify the startup variables for the server.',
180+
'docker-image' => 'Allows a user to modify the Docker image used when running the server.',
179181
],
180182
],
181183

app/Transformers/Api/Client/ServerTransformer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public function transform(Server $server): array
6363
'cpu' => $server->cpu,
6464
],
6565
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
66+
'docker_image' => $server->image,
6667
'egg_features' => $server->egg->inherit_features,
6768
'feature_limits' => [
6869
'databases' => $server->database_limit,

resources/scripts/api/server/getServer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface Server {
2222
port: number;
2323
};
2424
invocation: string;
25+
dockerImage: string;
2526
description: string;
2627
limits: {
2728
memory: number;
@@ -50,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
5051
name: data.name,
5152
node: data.node,
5253
invocation: data.invocation,
54+
dockerImage: data.docker_image,
5355
sftpDetails: {
5456
ip: data.sftp_details.ip,
5557
port: data.sftp_details.port,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import http from '@/api/http';
2+
3+
export default async (uuid: string, image: string): Promise<void> => {
4+
await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image });
5+
};

resources/scripts/api/swr/getServerStartup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ import { ServerEggVariable } from '@/api/server/types';
66
interface Response {
77
invocation: string;
88
variables: ServerEggVariable[];
9+
dockerImages: string[];
910
}
1011

1112
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
1213
const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
1314

1415
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
1516

16-
return { invocation: data.meta.startup_command, variables };
17+
return { invocation: data.meta.startup_command, variables, dockerImages: data.meta.docker_images || [] };
1718
}, { initialData, errorRetryCount: 3 });

resources/scripts/components/elements/InputSpinner.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,28 @@ import React from 'react';
22
import Spinner from '@/components/elements/Spinner';
33
import Fade from '@/components/elements/Fade';
44
import tw from 'twin.macro';
5+
import styled, { css } from 'styled-components/macro';
6+
import Select from '@/components/elements/Select';
7+
8+
const Container = styled.div<{ visible?: boolean }>`
9+
${tw`relative`};
10+
11+
${props => props.visible && css`
12+
& ${Select} {
13+
background-image: none;
14+
}
15+
`};
16+
`;
517

618
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
7-
<div css={tw`relative`}>
19+
<Container visible={visible}>
820
<Fade appear unmountOnExit in={visible} timeout={150}>
921
<div css={tw`absolute right-0 h-full flex items-center justify-end pr-3`}>
1022
<Spinner size={'small'}/>
1123
</div>
1224
</Fade>
1325
{children}
14-
</div>
26+
</Container>
1527
);
1628

1729
export default InputSpinner;

resources/scripts/components/server/startup/StartupContainer.tsx

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect } from 'react';
1+
import React, { useCallback, useEffect, useState } from 'react';
22
import TitledGreyBox from '@/components/elements/TitledGreyBox';
33
import tw from 'twin.macro';
44
import VariableBox from '@/components/server/startup/VariableBox';
@@ -9,15 +9,32 @@ import ServerError from '@/components/screens/ServerError';
99
import { httpErrorToHuman } from '@/api/http';
1010
import { ServerContext } from '@/state/server';
1111
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
12+
import Select from '@/components/elements/Select';
13+
import isEqual from 'react-fast-compare';
14+
import Input from '@/components/elements/Input';
15+
import setSelectedDockerImage from '@/api/server/setSelectedDockerImage';
16+
import InputSpinner from '@/components/elements/InputSpinner';
17+
import useFlash from '@/plugins/useFlash';
1218

1319
const StartupContainer = () => {
20+
const [ loading, setLoading ] = useState(false);
21+
const { clearFlashes, clearAndAddHttpError } = useFlash();
22+
1423
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
15-
const invocation = ServerContext.useStoreState(state => state.server.data!.invocation);
16-
const variables = ServerContext.useStoreState(state => state.server.data!.variables);
24+
const variables = ServerContext.useStoreState(({ server }) => ({
25+
variables: server.data!.variables,
26+
invocation: server.data!.invocation,
27+
dockerImage: server.data!.dockerImage,
28+
// @ts-ignore
29+
}), isEqual);
1730

18-
const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables });
31+
const { data, error, isValidating, mutate } = getServerStartup(uuid, {
32+
...variables,
33+
dockerImages: [ variables.dockerImage ],
34+
});
1935

2036
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
37+
const isCustomImage = data && !data.dockerImages.map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
2138

2239
useEffect(() => {
2340
// Since we're passing in initial data this will not trigger on mount automatically. We
@@ -36,6 +53,20 @@ const StartupContainer = () => {
3653
}));
3754
}, [ data ]);
3855

56+
const updateSelectedDockerImage = useCallback((v: React.ChangeEvent<HTMLSelectElement>) => {
57+
setLoading(true);
58+
clearFlashes('startup:image');
59+
60+
const image = v.currentTarget.value;
61+
setSelectedDockerImage(uuid, image)
62+
.then(() => setServerFromState(s => ({ ...s, dockerImage: image })))
63+
.catch(error => {
64+
console.error(error);
65+
clearAndAddHttpError({ key: 'startup:image', error });
66+
})
67+
.then(() => setLoading(false));
68+
}, [ uuid ]);
69+
3970
return (
4071
!data ?
4172
(!error || (error && isValidating)) ?
@@ -47,15 +78,49 @@ const StartupContainer = () => {
4778
onRetry={() => mutate()}
4879
/>
4980
:
50-
<ServerContentBlock title={'Startup Settings'}>
51-
<TitledGreyBox title={'Startup Command'}>
52-
<div css={tw`px-1 py-2`}>
53-
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
54-
{data.invocation}
55-
</p>
56-
</div>
57-
</TitledGreyBox>
58-
<div css={tw`grid gap-8 md:grid-cols-2 mt-10`}>
81+
<ServerContentBlock title={'Startup Settings'} showFlashKey={'startup:image'}>
82+
<div css={tw`flex`}>
83+
<TitledGreyBox title={'Startup Command'} css={tw`flex-1`}>
84+
<div css={tw`px-1 py-2`}>
85+
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
86+
{data.invocation}
87+
</p>
88+
</div>
89+
</TitledGreyBox>
90+
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 ml-10`}>
91+
{data.dockerImages.length > 1 && !isCustomImage ?
92+
<>
93+
<InputSpinner visible={loading}>
94+
<Select
95+
disabled={data.dockerImages.length < 2}
96+
onChange={updateSelectedDockerImage}
97+
defaultValue={variables.dockerImage}
98+
>
99+
{data.dockerImages.map(image => (
100+
<option key={image} value={image}>{image}</option>
101+
))}
102+
</Select>
103+
</InputSpinner>
104+
<p css={tw`text-xs text-neutral-300 mt-2`}>
105+
This is an advanced feature allowing you to select a Docker image to use when
106+
running this server instance.
107+
</p>
108+
</>
109+
:
110+
<>
111+
<Input disabled readOnly value={variables.dockerImage}/>
112+
{isCustomImage &&
113+
<p css={tw`text-xs text-neutral-300 mt-2`}>
114+
This {'server\'s'} Docker image has been manually set by an administrator and cannot
115+
be changed through this UI.
116+
</p>
117+
}
118+
</>
119+
}
120+
</TitledGreyBox>
121+
</div>
122+
<h3 css={tw`mt-8 mb-2 text-2xl`}>Variables</h3>
123+
<div css={tw`grid gap-8 md:grid-cols-2`}>
59124
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
60125
</div>
61126
</ServerContentBlock>

0 commit comments

Comments
 (0)