From a6ab61adba3522a7a2d6eb902467d216a39e13b4 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Wed, 15 Sep 2021 15:37:17 -0600 Subject: [PATCH] ui(admin): allow editing allocations for servers --- .../Nodes/AllocationController.php | 13 ++- .../Servers/UpdateServerRequest.php | 10 ++ .../nodes/allocations/createAllocation.ts | 2 +- .../admin/nodes/allocations/getAllocations.ts | 30 +----- .../scripts/api/admin/nodes/getAllocations.ts | 46 ++++++--- .../scripts/api/admin/servers/getServers.ts | 5 +- .../scripts/api/admin/servers/updateServer.ts | 4 + .../components/admin/servers/ServerRouter.tsx | 2 +- .../admin/servers/ServerSettingsContainer.tsx | 84 +++++++++++++--- .../admin/servers/ServerStartupContainer.tsx | 3 +- .../scripts/components/elements/Field.tsx | 6 +- .../scripts/components/elements/Select.tsx | 2 +- .../components/elements/SelectField.tsx | 96 ++++++++++++++----- 13 files changed, 219 insertions(+), 84 deletions(-) diff --git a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php index f987e6c8d..9845a4c62 100644 --- a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php @@ -6,6 +6,8 @@ use Pterodactyl\Models\Node; use Illuminate\Http\Response; use Pterodactyl\Models\Allocation; use Spatie\QueryBuilder\QueryBuilder; +use Spatie\QueryBuilder\AllowedFilter; +use Illuminate\Database\Eloquent\Builder; use Pterodactyl\Services\Allocations\AssignmentService; use Pterodactyl\Services\Allocations\AllocationDeletionService; use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException; @@ -46,7 +48,16 @@ class AllocationController extends ApplicationApiController } $allocations = QueryBuilder::for(Allocation::query()->where('node_id', '=', $node->id)) - ->allowedFilters(['id', 'ip', 'port', 'alias', 'server_id']) + ->allowedFilters([ + 'id', 'ip', 'port', 'alias', + AllowedFilter::callback('server_id', function (Builder $query, $value) { + if ($value === '0') { + $query->whereNull('server_id'); + } else { + $query->where('server_id', '=', $value); + } + }), + ]) ->allowedSorts(['id', 'ip', 'port', 'server_id']) ->paginate($perPage); diff --git a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php index 21a114061..8d22a9863 100644 --- a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php @@ -28,6 +28,12 @@ class UpdateServerRequest extends ApplicationApiRequest 'databases' => $rules['database_limit'], 'allocations' => $rules['allocation_limit'], 'backups' => $rules['backup_limit'], + + 'allocation_id' => 'bail|exists:allocations,id', + 'add_allocations' => 'bail|array', + 'add_allocations.*' => 'integer', + 'remove_allocations' => 'bail|array', + 'remove_allocations.*' => 'integer', ]; } @@ -52,6 +58,10 @@ class UpdateServerRequest extends ApplicationApiRequest 'database_limit' => array_get($data, 'databases'), 'allocation_limit' => array_get($data, 'allocations'), 'backup_limit' => array_get($data, 'backups'), + + 'allocation_id' => array_get($data, 'allocation_id'), + 'add_allocations' => array_get($data, 'add_allocations'), + 'remove_allocations' => array_get($data, 'remove_allocations'), ]; } } diff --git a/resources/scripts/api/admin/nodes/allocations/createAllocation.ts b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts index 40455e33f..89bacbd4d 100644 --- a/resources/scripts/api/admin/nodes/allocations/createAllocation.ts +++ b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts @@ -1,5 +1,5 @@ import http from '@/api/http'; -import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/allocations/getAllocations'; +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; export interface Values { ip: string; diff --git a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts index ade16a48b..55d01152b 100644 --- a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts +++ b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts @@ -1,35 +1,9 @@ -import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; -import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; +import http, { getPaginationSet, PaginatedResult } from '@/api/http'; import { useContext } from 'react'; import useSWR from 'swr'; import { createContext } from '@/api/admin'; -export interface Allocation { - id: number; - ip: string; - port: number; - alias: string | null; - serverId: number | null; - assigned: boolean; - - relations: { - server?: Server; - } -} - -export const rawDataToAllocation = ({ attributes }: FractalResponseData): Allocation => ({ - id: attributes.id, - ip: attributes.ip, - port: attributes.port, - alias: attributes.ip_alias || null, - serverId: attributes.server_id, - assigned: attributes.assigned, - - relations: { - server: attributes.relationships?.server?.object === 'server' ? rawDataToServer(attributes.relationships.server as FractalResponseData) : undefined, - }, -}); - export interface Filters { id?: string; ip?: string; diff --git a/resources/scripts/api/admin/nodes/getAllocations.ts b/resources/scripts/api/admin/nodes/getAllocations.ts index 4809d2703..389fd49bd 100644 --- a/resources/scripts/api/admin/nodes/getAllocations.ts +++ b/resources/scripts/api/admin/nodes/getAllocations.ts @@ -1,26 +1,50 @@ import http, { FractalResponseData } from '@/api/http'; +import { rawDataToServer, Server } from '@/api/admin/servers/getServers'; export interface Allocation { id: number; ip: string; - alias: string | null; port: number; - notes: string | null; + alias: string | null; + serverId: number | null; assigned: boolean; + + relations: { + server?: Server; + } } -export const rawDataToAllocation = (data: FractalResponseData): Allocation => ({ - id: data.attributes.id, - ip: data.attributes.ip, - alias: data.attributes.ip_alias, - port: data.attributes.port, - notes: data.attributes.notes, - assigned: data.attributes.assigned, +export const rawDataToAllocation = ({ attributes }: FractalResponseData): Allocation => ({ + id: attributes.id, + ip: attributes.ip, + port: attributes.port, + alias: attributes.ip_alias || null, + serverId: attributes.server_id, + assigned: attributes.assigned, + + relations: { + server: attributes.relationships?.server?.object === 'server' ? rawDataToServer(attributes.relationships.server as FractalResponseData) : undefined, + }, }); -export default (id: string | number): Promise => { +export interface Filters { + ip?: string + /* eslint-disable camelcase */ + server_id?: string; + /* eslint-enable camelcase */ +} + +export default (id: string | number, filters: Filters = {}, include: string[] = []): Promise => { + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + return new Promise((resolve, reject) => { - http.get(`/api/application/nodes/${id}/allocations`) + http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), ...params } }) .then(({ data }) => resolve((data.data || []).map(rawDataToAllocation))) .catch(reject); }); diff --git a/resources/scripts/api/admin/servers/getServers.ts b/resources/scripts/api/admin/servers/getServers.ts index 747c814fc..5dd7deba1 100644 --- a/resources/scripts/api/admin/servers/getServers.ts +++ b/resources/scripts/api/admin/servers/getServers.ts @@ -1,7 +1,8 @@ +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; import { useContext } from 'react'; import useSWR from 'swr'; import { createContext } from '@/api/admin'; -import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; +import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http'; import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; import { User, rawDataToUser } from '@/api/admin/users/getUsers'; @@ -48,6 +49,7 @@ export interface Server { updatedAt: Date; relations: { + allocations?: Allocation[]; egg?: Egg; node?: Node; user?: User; @@ -96,6 +98,7 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server => updatedAt: new Date(attributes.updated_at), relations: { + allocations: ((attributes.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToAllocation), egg: attributes.relationships?.egg?.object === 'egg' ? rawDataToEgg(attributes.relationships.egg as FractalResponseData) : undefined, node: attributes.relationships?.node?.object === 'node' ? rawDataToNode(attributes.relationships.node as FractalResponseData) : undefined, user: attributes.relationships?.user?.object === 'user' ? rawDataToUser(attributes.relationships.user as FractalResponseData) : undefined, diff --git a/resources/scripts/api/admin/servers/updateServer.ts b/resources/scripts/api/admin/servers/updateServer.ts index 59ab5bf68..d4ac2b750 100644 --- a/resources/scripts/api/admin/servers/updateServer.ts +++ b/resources/scripts/api/admin/servers/updateServer.ts @@ -17,6 +17,10 @@ export interface Values { databases: number; allocations: number; backups: number; + + allocationId: number; + addAllocations: number[]; + removeAllocations: number[]; } export default (id: number, server: Partial, include: string[] = []): Promise => { diff --git a/resources/scripts/components/admin/servers/ServerRouter.tsx b/resources/scripts/components/admin/servers/ServerRouter.tsx index 2e67dcdb1..de82603e2 100644 --- a/resources/scripts/components/admin/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/servers/ServerRouter.tsx @@ -40,7 +40,7 @@ const ServerRouter = () => { useEffect(() => { clearFlashes('server'); - getServer(Number(match.params?.id), [ 'user' ]) + getServer(Number(match.params?.id), [ 'allocations', 'user' ]) .then(server => setServer(server)) .catch(error => { console.error(error); diff --git a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx index 4293691a6..a92c9e841 100644 --- a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx @@ -1,5 +1,9 @@ +import getAllocations from '@/api/admin/nodes/getAllocations'; import { Server } from '@/api/admin/servers/getServers'; import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import SelectField, { AsyncSelectField, Option } from '@/components/elements/SelectField'; import { faBalanceScale, faCogs, faConciergeBell, faNetworkWired } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; import AdminBox from '@/components/admin/AdminBox'; @@ -63,7 +67,7 @@ export function ServerResourceContainer () { const { isSubmitting } = useFormikContext(); return ( - +
@@ -72,8 +76,8 @@ export function ServerResourceContainer () { id={'cpu'} name={'cpu'} label={'CPU Limit'} - type={'string'} - description={'Each physical core on the system is considered to be 100%. Setting this value to 0 will allow a server to use CPU time without restrictions.'} + type={'text'} + description={'Each thread on the system is considered to be 100%. Setting this value to 0 will allow the server to use CPU time without restriction.'} />
@@ -82,8 +86,8 @@ export function ServerResourceContainer () { id={'threads'} name={'threads'} label={'CPU Pinning'} - type={'string'} - description={'Advanced: Enter the specific CPU cores that this process can run on, or leave blank to allow all cores. This can be a single number, or a comma seperated list. Example: 0, 0-1,3, or 0,1,3,4.'} + type={'text'} + description={'Advanced: Enter the specific CPU cores that this server can run on, or leave blank to allow all cores. This can be a single number, and or a comma seperated list, and or a dashed range. Example: 0, 0-1,3, or 0,1,3,4. It is recommended to leave this value blank and let the CPU handle balancing the load.'} /> @@ -132,11 +136,11 @@ export function ServerResourceContainer () {
-
+
@@ -157,7 +161,7 @@ export function ServerSettingsContainer ({ server }: { server?: Server }) { id={'name'} name={'name'} label={'Server Name'} - type={'string'} + type={'text'} />
@@ -166,7 +170,7 @@ export function ServerSettingsContainer ({ server }: { server?: Server }) { id={'externalId'} name={'externalId'} label={'External Identifier'} - type={'number'} + type={'text'} /> @@ -180,12 +184,52 @@ export function ServerSettingsContainer ({ server }: { server?: Server }) { ); } -export function ServerAllocationsContainer () { +export function ServerAllocationsContainer ({ server }: { server: Server }) { const { isSubmitting } = useFormikContext(); + const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => { + const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' }); + callback(allocations.map(a => { + return { value: a.id.toString(), label: a.ip + ':' + a.port }; + })); + }; + return ( - + + +
+ + +
+ + + + { + return { value: a.id.toString(), label: a.ip + ':' + a.port }; + }) || []} + isMulti + isSearchable + css={tw`mb-2`} + />
); } @@ -206,11 +250,17 @@ export default function ServerSettingsContainer2 () { ); } - const submit = (values: Values2, { setSubmitting }: FormikHelpers) => { + const submit = (values: Values2, { setSubmitting, setFieldValue }: FormikHelpers) => { clearFlashes('server'); - updateServer(server.id, { ...values, oomDisabled: !values.oomKiller }) - .then(() => setServer({ ...server, ...values })) + updateServer(server.id, { ...values, oomDisabled: !values.oomKiller }, [ 'allocations', 'user' ]) + .then(s => { + setServer({ ...server, ...s }); + + // TODO: Figure out how to properly clear react-selects for allocations. + setFieldValue('addAllocations', []); + setFieldValue('removeAllocations', []); + }) .catch(error => { console.error(error); clearAndAddHttpError({ key: 'server', error }); @@ -239,6 +289,10 @@ export default function ServerSettingsContainer2 () { databases: server.featureLimits.databases, allocations: server.featureLimits.allocations, backups: server.featureLimits.backups, + + allocationId: server.allocationId, + addAllocations: [] as number[], + removeAllocations: [] as number[], }} validationSchema={object().shape({ })} @@ -256,7 +310,7 @@ export default function ServerSettingsContainer2 () {
- +
diff --git a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx index 7f306efe7..18fc0ff41 100644 --- a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx @@ -1,4 +1,3 @@ -import { Egg } from '@/api/admin/eggs/getEgg'; import EggSelect from '@/components/admin/servers/EggSelect'; import NestSelect from '@/components/admin/servers/NestSelect'; import FormikSwitch from '@/components/elements/FormikSwitch'; @@ -107,7 +106,7 @@ function ServerImageContainer () { export default function ServerStartupContainer () { const { clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); - const [ egg, setEgg ] = useState(null); + // const [ egg, setEgg ] = useState(null); const server = Context.useStoreState(state => state.server); diff --git a/resources/scripts/components/elements/Field.tsx b/resources/scripts/components/elements/Field.tsx index b9e319e1d..f45ce0fb4 100644 --- a/resources/scripts/components/elements/Field.tsx +++ b/resources/scripts/components/elements/Field.tsx @@ -3,6 +3,7 @@ import { Field as FormikField, FieldProps } from 'formik'; import Input from '@/components/elements/Input'; import Label from '@/components/elements/Label'; import InputError from '@/components/elements/InputError'; +import tw from 'twin.macro'; interface OwnProps { name: string; @@ -20,7 +21,10 @@ const Field = forwardRef(({ id, name, light = false, la ({ field, form: { errors, touched } }: FieldProps) => (
{label && - +
+ + {/*{description && }*/} +
} ` - ${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`}; + ${tw`shadow-none block p-3 pr-8 rounded border-2 w-full text-sm transition-colors duration-150 ease-linear`}; &, &:hover:not(:disabled), &:focus { ${tw`outline-none`}; diff --git a/resources/scripts/components/elements/SelectField.tsx b/resources/scripts/components/elements/SelectField.tsx index 55b41a9ec..481d37333 100644 --- a/resources/scripts/components/elements/SelectField.tsx +++ b/resources/scripts/components/elements/SelectField.tsx @@ -2,6 +2,7 @@ import { CSSObject } from '@emotion/serialize'; import { Field as FormikField, FieldProps } from 'formik'; import React, { forwardRef } from 'react'; import Select, { ContainerProps, ControlProps, GroupProps, IndicatorContainerProps, IndicatorProps, InputProps, MenuListComponentProps, MenuProps, MultiValueProps, OptionProps, PlaceholderProps, SingleValueProps, StylesConfig, ValueContainerProps } from 'react-select'; +import Async from 'react-select/async'; import Creatable from 'react-select/creatable'; import tw, { theme } from 'twin.macro'; import Label from '@/components/elements/Label'; @@ -219,7 +220,7 @@ export interface Option { label: string; } -interface Props { +interface SelectFieldProps { id?: string; name: string; label?: string; @@ -242,26 +243,24 @@ interface Props { className?: string; } -const SelectField = forwardRef(({ id, name, label, description, validate, className, isMulti, isCreatable, ...props }, ref) => { - const { options } = props; +const SelectField = forwardRef( + function Select2 ({ id, name, label, description, validate, className, isMulti, isCreatable, ...props }, ref) { + const { options } = props; - const onChange = (options: Option | Option[], name: string, setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void) => { - if (isMulti) { - setFieldValue(name, (options as Option[]).map(o => o.value)); - return; - } + const onChange = (options: Option | Option[], name: string, setFieldValue: (field: string, value: any, shouldValidate?: boolean) => void) => { + if (isMulti) { + setFieldValue(name, (options as Option[]).map(o => o.value)); + return; + } - setFieldValue(name, (options as Option).value); - }; + setFieldValue(name, (options as Option).value); + }; - return ( - - { - ({ field, form: { errors, touched, setFieldValue } }: FieldProps) => ( + return ( + + {({ field, form: { errors, touched, setFieldValue } }: FieldProps) => (
- {label && - - } + {label && } {isCreatable ? (({ id, name, label, descripti description ?

{description}

: null }
- ) + )} +
+ ); + } +); + +interface AsyncSelectFieldProps { + id?: string; + name: string; + label?: string; + description?: string; + placeholder?: string; + validate?: (value: any) => undefined | string | Promise; + + isMulti?: boolean; + + className?: string; + + loadOptions(inputValue: string, callback: (options: Array
- ); -}); -SelectField.displayName = 'SelectField'; + + setFieldValue(name, Number((options as Option).value)); + }; + + return ( + + {({ field, form: { errors, touched, setFieldValue } }: FieldProps) => ( +
+ {label && } + onChange(o, name, setFieldValue)} + isMulti={isMulti} + /> + {touched[field.name] && errors[field.name] ? +

+ {(errors[field.name] as string).charAt(0).toUpperCase() + (errors[field.name] as string).slice(1)} +

+ : + description ?

{description}

: null + } +
+ )} +
+ ); + } +); export default SelectField; +export { AsyncSelectField };