diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkController.php b/app/Http/Controllers/Api/Client/Servers/NetworkController.php index fbea03033..e1421750e 100644 --- a/app/Http/Controllers/Api/Client/Servers/NetworkController.php +++ b/app/Http/Controllers/Api/Client/Servers/NetworkController.php @@ -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(); } diff --git a/app/Http/Requests/Api/Client/Servers/Network/SetPrimaryAllocationRequest.php b/app/Http/Requests/Api/Client/Servers/Network/SetPrimaryAllocationRequest.php new file mode 100644 index 000000000..545c9f440 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Network/SetPrimaryAllocationRequest.php @@ -0,0 +1,28 @@ + 'required|string', + 'port' => 'required|numeric|min:1024|max:65535', + ]; + } +} diff --git a/package.json b/package.json index 910b95576..99bcf0d37 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 21111ba9b..9ac1b64f8 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -75,12 +75,15 @@ export interface FractalResponseData { object: string; attributes: { [k: string]: any; - relationships?: { - [k: string]: FractalResponseData; - }; + relationships?: Record; }; } +export interface FractalResponseList { + object: 'list'; + data: FractalResponseData[]; +} + export interface PaginatedResult { items: T[]; pagination: PaginationDataSet; diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index fd4534287..34765337f 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -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 || []), ])) diff --git a/resources/scripts/api/server/network/getServerAllocations.ts b/resources/scripts/api/server/network/getServerAllocations.ts new file mode 100644 index 000000000..47ffcec91 --- /dev/null +++ b/resources/scripts/api/server/network/getServerAllocations.ts @@ -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 => { + const { data } = await http.get(`/api/client/servers/${uuid}/network`); + + return (data.data || []).map(rawDataToServerAllocation); +}; diff --git a/resources/scripts/api/server/network/setPrimaryServerAllocation.ts b/resources/scripts/api/server/network/setPrimaryServerAllocation.ts new file mode 100644 index 000000000..f63f2d521 --- /dev/null +++ b/resources/scripts/api/server/network/setPrimaryServerAllocation.ts @@ -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 => { + const { data } = await http.put(`/api/client/servers/${uuid}/network/primary`, { ip, port }); + + return rawDataToServerAllocation(data); +}; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts new file mode 100644 index 000000000..2aad632b2 --- /dev/null +++ b/resources/scripts/api/transformers.ts @@ -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, +}); diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index b9ac57c4e..300f1a9ea 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -68,6 +68,7 @@ const ButtonStyle = styled.button>` &: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`}; } `}; diff --git a/resources/scripts/components/elements/PageContentBlock.tsx b/resources/scripts/components/elements/PageContentBlock.tsx index 9503839ff..f32c42ce2 100644 --- a/resources/scripts/components/elements/PageContentBlock.tsx +++ b/resources/scripts/components/elements/PageContentBlock.tsx @@ -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 }) => ( <> + {showFlashKey && + + } {children} diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx new file mode 100644 index 000000000..776713ee9 --- /dev/null +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -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(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 ( + + {!data ? + + : + data.map(({ ip, port, alias, isDefault }, index) => ( + 0 ? tw`mt-2` : undefined}> +
+ +
+
+ {alias || ip} + +
+
+ :{port} + +
+
+ {isDefault ? + + Primary + + : + + + + } +
+
+ )) + } +
+ ); +}; + +export default NetworkContainer; diff --git a/resources/scripts/components/server/settings/ServerAllocationsContainer.tsx b/resources/scripts/components/server/settings/ServerAllocationsContainer.tsx deleted file mode 100644 index 3e76d5d9d..000000000 --- a/resources/scripts/components/server/settings/ServerAllocationsContainer.tsx +++ /dev/null @@ -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 ( - - {allocations.map(({ ip, port, alias, isDefault }, index) => ( - 0 ? tw`mt-2` : undefined}> -
- -
-
- {alias || ip} - -
-
- :{port} - -
-
- {isDefault ? - - Default - - : - null - } -
-
- ))} -
- ); -}; diff --git a/resources/scripts/components/server/settings/SettingsContainer.tsx b/resources/scripts/components/server/settings/SettingsContainer.tsx index 6f632ad19..e060fedf8 100644 --- a/resources/scripts/components/server/settings/SettingsContainer.tsx +++ b/resources/scripts/components/server/settings/SettingsContainer.tsx @@ -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(state => state.user.data!); @@ -61,6 +60,8 @@ export default () => { + +
@@ -70,9 +71,6 @@ export default () => {
-
- -
); diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index baa9723a4..9df270eaa 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -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 }>) Backups + + Network + Settings @@ -125,6 +129,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) /> + diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts index 09b641c10..96b30824f 100644 --- a/resources/scripts/state/flashes.ts +++ b/resources/scripts/state/flashes.ts @@ -5,6 +5,7 @@ export interface FlashStore { items: FlashMessage[]; addFlash: Action; addError: Action; + clearAndAddError: Action; clearFlashes: Action; } @@ -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) : []; }), diff --git a/routes/api-client.php b/routes/api-client.php index 9f6b2c075..9bea11952 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -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 () { diff --git a/yarn.lock b/yarn.lock index 3baad2306..62c1da6fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"