Support modifying the primary allocation for a server

This commit is contained in:
Dane Everitt 2020-07-09 19:56:46 -07:00
parent bfb28f949d
commit fc9054312d
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
17 changed files with 230 additions and 87 deletions

View file

@ -3,10 +3,14 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
use Pterodactyl\Transformers\Api\Client\AllocationTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest;
class NetworkController extends ClientApiController
{
@ -15,16 +19,25 @@ class NetworkController extends ClientApiController
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $serverRepository;
/**
* NetworkController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\AllocationRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
*/
public function __construct(AllocationRepository $repository)
{
public function __construct(
AllocationRepository $repository,
ServerRepository $serverRepository
) {
parent::__construct();
$this->repository = $repository;
$this->serverRepository = $serverRepository;
}
/**
@ -37,11 +50,40 @@ class NetworkController extends ClientApiController
*/
public function index(GetNetworkRequest $request, Server $server): array
{
$allocations = $this->repository->findWhere([
['server_id', '=', $server->id],
]);
return $this->fractal->collection($server->allocations)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}
return $this->fractal->collection($allocations)
/**
* Set the primary allocation for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function storePrimary(SetPrimaryAllocationRequest $request, Server $server): array
{
try {
/** @var \Pterodactyl\Models\Allocation $allocation */
$allocation = $this->repository->findFirstWhere([
'server_id' => $server->id,
'ip' => $request->input('ip'),
'port' => $request->input('port'),
]);
} catch (ModelNotFoundException $exception) {
throw new DisplayException(
'The IP and port you selected are not available for this server.'
);
}
$this->serverRepository->update($server->id, ['allocation_id' => $allocation->id]);
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))
->toArray();
}

View file

@ -0,0 +1,28 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Network;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class SetPrimaryAllocationRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission(): string
{
return Permission::ACTION_ALLOCIATION_UPDATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'ip' => 'required|string',
'port' => 'required|numeric|min:1024|max:65535',
];
}
}

View file

@ -34,6 +34,7 @@
"sockette": "^2.0.6",
"styled-components": "^5.1.1",
"styled-components-breakpoint": "^3.0.0-preview.20",
"swr": "^0.2.3",
"uuid": "^3.3.2",
"xterm": "^3.14.4",
"xterm-addon-attach": "^0.1.0",

View file

@ -75,12 +75,15 @@ export interface FractalResponseData {
object: string;
attributes: {
[k: string]: any;
relationships?: {
[k: string]: FractalResponseData;
};
relationships?: Record<string, FractalResponseData | FractalResponseList>;
};
}
export interface FractalResponseList {
object: 'list';
data: FractalResponseData[];
}
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationDataSet;

View file

@ -1,4 +1,5 @@
import http from '@/api/http';
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export interface Allocation {
ip: string;
@ -35,7 +36,7 @@ export interface Server {
isInstalling: boolean;
}
export const rawDataToServerObject = (data: any): Server => ({
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
id: data.identifier,
uuid: data.uuid,
name: data.name,
@ -45,23 +46,18 @@ export const rawDataToServerObject = (data: any): Server => ({
port: data.sftp_details.port,
},
description: data.description ? ((data.description.length > 0) ? data.description : null) : null,
allocations: (data.allocations || []).map((datum: any) => ({
ip: datum.ip,
alias: datum.ip_alias,
port: datum.port,
isDefault: datum.is_default,
})),
limits: { ...data.limits },
featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended,
isInstalling: data.is_installing,
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
});
export default (uuid: string): Promise<[ Server, string[] ]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`)
.then(({ data }) => resolve([
rawDataToServerObject(data.attributes),
rawDataToServerObject(data),
// eslint-disable-next-line camelcase
data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []),
]))

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
import { Allocation } from '@/api/server/getServer';
export default async (uuid: string): Promise<Allocation[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/network`);
return (data.data || []).map(rawDataToServerAllocation);
};

View file

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import http from '@/api/http';
import { rawDataToServerAllocation } from '@/api/transformers';
export default async (uuid: string, ip: string, port: number): Promise<Allocation> => {
const { data } = await http.put(`/api/client/servers/${uuid}/network/primary`, { ip, port });
return rawDataToServerAllocation(data);
};

View file

@ -0,0 +1,9 @@
import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
ip: data.attributes.ip,
alias: data.attributes.ip_alias,
port: data.attributes.port,
isDefault: data.attributes.is_default,
});

View file

@ -68,6 +68,7 @@ const ButtonStyle = styled.button<Omit<Props, 'isLoading'>>`
&:hover:not(:disabled) {
${tw`border-neutral-500 text-neutral-100`};
${props => props.color === 'red' && tw`bg-red-500 border-red-600 text-red-50`};
${props => props.color === 'primary' && tw`bg-primary-500 border-primary-600 text-primary-50`};
${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`};
}
`};

View file

@ -2,11 +2,15 @@ import React from 'react';
import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
const PageContentBlock: React.FC<{ className?: string }> = ({ children, className }) => (
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
<ContentContainer css={tw`my-10`} className={className}>
{showFlashKey &&
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
}
{children}
</ContentContainer>
<ContentContainer css={tw`mb-4`}>

View file

@ -0,0 +1,85 @@
import React, { useEffect } from 'react';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/macro';
import PageContentBlock from '@/components/elements/PageContentBlock';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useSWR from 'swr';
import getServerAllocations from '@/api/server/network/getServerAllocations';
import { Allocation } from '@/api/server/getServer';
import Spinner from '@/components/elements/Spinner';
import setPrimaryServerAllocation from '@/api/server/network/setPrimaryServerAllocation';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const NetworkContainer = () => {
const server = useServer();
const { clearFlashes, clearAndAddError } = useFlash();
const { data, error, mutate } = useSWR<Allocation[]>(server.uuid, key => getServerAllocations(key), { initialData: server.allocations });
const setPrimaryAllocation = (ip: string, port: number) => {
clearFlashes('server:network');
mutate(data?.map(a => (a.ip === ip && a.port === port) ? { ...a, isDefault: true } : { ...a, isDefault: false }), false);
setPrimaryServerAllocation(server.uuid, ip, port)
.catch(error => clearAndAddError({ key: 'server:network', message: httpErrorToHuman(error) }));
};
useEffect(() => {
if (error) {
clearAndAddError({ key: 'server:network', message: error });
}
}, [ error ]);
return (
<PageContentBlock showFlashKey={'server:network'}>
{!data ?
<Spinner size={'large'} centered/>
:
data.map(({ ip, port, alias, isDefault }, index) => (
<GreyRowBox key={`${ip}:${port}`} css={index > 0 ? tw`mt-2` : undefined}>
<div css={tw`pl-4 pr-6 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
</div>
<div css={tw`mr-4`}>
<Code>{alias || ip}</Code>
<Label>IP Address</Label>
</div>
<div>
<Code>:{port}</Code>
<Label>Port</Label>
</div>
<div css={tw`flex-1 text-right`}>
{isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>
Primary
</span>
:
<Can action={'allocations.update'}>
<Button
isSecondary
size={'xsmall'}
color={'primary'}
onClick={() => setPrimaryAllocation(ip, port)}
>
Make Primary
</Button>
</Can>
}
</div>
</GreyRowBox>
))
}
</PageContentBlock>
);
};
export default NetworkContainer;

View file

@ -1,63 +0,0 @@
import React from 'react';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/macro';
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const Row = styled.div`
${tw`flex items-center py-2 pl-4 pr-5 border-l-4 border-transparent transition-colors duration-150`};
& svg {
${tw`transition-colors duration-150`};
}
&:hover {
${tw`border-cyan-400`};
svg {
${tw`text-neutral-100`};
}
${Label} {
${tw`text-neutral-200`};
}
}
`;
export default () => {
const allocations = ServerContext.useStoreState(state => state.server.data!.allocations);
return (
<TitledGreyBox title={'Allocated Ports'}>
{allocations.map(({ ip, port, alias, isDefault }, index) => (
<Row key={`${ip}:${port}`} css={index > 0 ? tw`mt-2` : undefined}>
<div css={tw`mr-4 text-neutral-400`}>
<FontAwesomeIcon icon={faNetworkWired}/>
</div>
<div css={tw`mr-4`}>
<Code>{alias || ip}</Code>
<Label>IP Address</Label>
</div>
<div>
<Code>:{port}</Code>
<Label>Port</Label>
</div>
<div css={tw`flex-1 text-right`}>
{isDefault ?
<span css={tw`bg-green-500 py-1 px-2 rounded text-green-50 text-xs`}>
Default
</span>
:
null
}
</div>
</Row>
))}
</TitledGreyBox>
);
};

View file

@ -13,7 +13,6 @@ import tw from 'twin.macro';
import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label';
import { LinkButton } from '@/components/elements/Button';
import ServerAllocationsContainer from '@/components/server/settings/ServerAllocationsContainer';
export default () => {
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
@ -61,6 +60,8 @@ export default () => {
</div>
</TitledGreyBox>
</Can>
</div>
<div css={tw`w-full mt-6 md:flex-1 md:mt-0`}>
<Can action={'settings.rename'}>
<div css={tw`mb-6 md:mb-10`}>
<RenameServerBox/>
@ -70,9 +71,6 @@ export default () => {
<ReinstallServerBox/>
</Can>
</div>
<div css={tw`w-full mt-6 md:flex-1 md:mt-0`}>
<ServerAllocationsContainer/>
</div>
</div>
</PageContentBlock>
);

View file

@ -24,6 +24,7 @@ import { useStoreState } from 'easy-peasy';
import useServer from '@/plugins/useServer';
import ScreenBlock from '@/components/screens/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const { rootAdmin } = useStoreState(state => state.user.data!);
@ -88,6 +89,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Can action={'backup.*'}>
<NavLink to={`${match.url}/backups`}>Backups</NavLink>
</Can>
<Can action={'allocations.*'}>
<NavLink to={`${match.url}/network`}>Network</NavLink>
</Can>
<Can action={[ 'settings.*', 'file.sftp' ]} matchAny>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</Can>
@ -125,6 +129,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
/>
<Route path={`${match.path}/users`} component={UsersContainer} exact/>
<Route path={`${match.path}/backups`} component={BackupContainer} exact/>
<Route path={`${match.path}/network`} component={NetworkContainer} exact/>
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
<Route path={'*'} component={NotFound}/>
</Switch>

View file

@ -5,6 +5,7 @@ export interface FlashStore {
items: FlashMessage[];
addFlash: Action<FlashStore, FlashMessage>;
addError: Action<FlashStore, { message: string; key?: string }>;
clearAndAddError: Action<FlashStore, { message: string, key: string }>;
clearFlashes: Action<FlashStore, string | void>;
}
@ -18,12 +19,19 @@ export interface FlashMessage {
const flashes: FlashStore = {
items: [],
addFlash: action((state, payload) => {
state.items.push(payload);
}),
addError: action((state, payload) => {
state.items.push({ type: 'error', title: 'Error', ...payload });
}),
clearAndAddError: action((state, payload) => {
state.items = [ { type: 'error', title: 'Error', ...payload } ];
}),
clearFlashes: action((state, payload) => {
state.items = payload ? state.items.filter(flashes => flashes.key !== payload) : [];
}),

View file

@ -76,6 +76,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::group(['prefix' => '/network'], function () {
Route::get('/', 'Servers\NetworkController@index');
Route::put('/primary', 'Servers\NetworkController@storePrimary');
});
Route::group(['prefix' => '/users'], function () {

View file

@ -3113,7 +3113,7 @@ extglob@^2.0.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"
fast-deep-equal@^2.0.1:
fast-deep-equal@2.0.1, fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
@ -6534,6 +6534,13 @@ svg-url-loader@^6.0.0:
file-loader "~6.0.0"
loader-utils "~2.0.0"
swr@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/swr/-/swr-0.2.3.tgz#e0fb260d27f12fafa2388312083368f45127480d"
integrity sha512-JhuuD5ojqgjAQpZAhoPBd8Di0Mr1+ykByVKuRJdtKaxkUX/y8kMACWKkLgLQc8pcDOKEAnbIreNjU7HfqI9nHQ==
dependencies:
fast-deep-equal "2.0.1"
symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"