Support modifying the primary allocation for a server
This commit is contained in:
parent
bfb28f949d
commit
fc9054312d
17 changed files with 230 additions and 87 deletions
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 || []),
|
||||
]))
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
9
resources/scripts/api/transformers.ts
Normal file
9
resources/scripts/api/transformers.ts
Normal 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,
|
||||
});
|
|
@ -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`};
|
||||
}
|
||||
`};
|
||||
|
|
|
@ -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`}>
|
||||
|
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) : [];
|
||||
}),
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue