From 3c01dbbcc5c3dbe96f3ef8230bbd28dfc8d85351 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Sun, 12 Sep 2021 19:40:10 -0600 Subject: [PATCH] ui(admin): add allocation table, implement allocation creator --- .../Nodes/AllocationController.php | 6 +- .../Api/Application/AllocationTransformer.php | 1 + package.json | 2 +- .../nodes/allocations/createAllocation.ts | 16 ++ .../admin/nodes/allocations/getAllocations.ts | 65 +++++++ .../scripts/api/admin/servers/getServers.ts | 14 +- .../admin/nodes/NodeAllocationContainer.tsx | 21 ++- .../nodes/allocations/AllocationTable.tsx | 160 ++++++++++++++++++ .../CreateAllocationForm.tsx | 21 +++ .../components/elements/SelectField.tsx | 2 +- resources/scripts/state/admin/allocations.ts | 27 +++ resources/scripts/state/admin/index.ts | 3 + routes/api-application.php | 136 +++++++-------- yarn.lock | 10 +- 14 files changed, 397 insertions(+), 87 deletions(-) create mode 100644 resources/scripts/api/admin/nodes/allocations/createAllocation.ts create mode 100644 resources/scripts/api/admin/nodes/allocations/getAllocations.ts create mode 100644 resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx rename resources/scripts/components/admin/nodes/{ => allocations}/CreateAllocationForm.tsx (78%) create mode 100644 resources/scripts/state/admin/allocations.ts diff --git a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php index ffa4c2fe8..f987e6c8d 100644 --- a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php @@ -5,6 +5,7 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Nodes; use Pterodactyl\Models\Node; use Illuminate\Http\Response; use Pterodactyl\Models\Allocation; +use Spatie\QueryBuilder\QueryBuilder; use Pterodactyl\Services\Allocations\AssignmentService; use Pterodactyl\Services\Allocations\AllocationDeletionService; use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException; @@ -44,7 +45,10 @@ class AllocationController extends ApplicationApiController throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } - $allocations = $node->allocations()->paginate($perPage); + $allocations = QueryBuilder::for(Allocation::query()->where('node_id', '=', $node->id)) + ->allowedFilters(['id', 'ip', 'port', 'alias', 'server_id']) + ->allowedSorts(['id', 'ip', 'port', 'server_id']) + ->paginate($perPage); return $this->fractal->collection($allocations) ->transformWith(AllocationTransformer::class) diff --git a/app/Transformers/Api/Application/AllocationTransformer.php b/app/Transformers/Api/Application/AllocationTransformer.php index 6a1734185..4027c5d07 100644 --- a/app/Transformers/Api/Application/AllocationTransformer.php +++ b/app/Transformers/Api/Application/AllocationTransformer.php @@ -28,6 +28,7 @@ class AllocationTransformer extends Transformer 'alias' => $model->ip_alias, 'port' => $model->port, 'notes' => $model->notes, + 'server_id' => $model->server_id, 'assigned' => !is_null($model->server_id), ]; } diff --git a/package.json b/package.json index e5782c7e1..cad0acf62 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "sockette": "^2.0.6", "styled-components": "^5.3.1", "styled-components-breakpoint": "^3.0.0-preview.20", - "swr": "^1.0.0", + "swr": "^1.0.1", "uuid": "^3.4.0", "xterm": "^4.13.0", "xterm-addon-attach": "^0.6.0", diff --git a/resources/scripts/api/admin/nodes/allocations/createAllocation.ts b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts new file mode 100644 index 000000000..40455e33f --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts @@ -0,0 +1,16 @@ +import http from '@/api/http'; +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/allocations/getAllocations'; + +export interface Values { + ip: string; + ports: number[]; + alias?: string; +} + +export default (id: string | number, values: Values, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/application/nodes/${id}/allocations`, values, { params: { include: include.join(',') } }) + .then(({ data }) => resolve((data || []).map(rawDataToAllocation))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts new file mode 100644 index 000000000..ade16a48b --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts @@ -0,0 +1,65 @@ +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; +import http, { FractalResponseData, 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; + port?: string; +} + +export const Context = createContext(); + +export default (id: string | number, include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'allocations', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToAllocation), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/servers/getServers.ts b/resources/scripts/api/admin/servers/getServers.ts index b06be0715..c0f546069 100644 --- a/resources/scripts/api/admin/servers/getServers.ts +++ b/resources/scripts/api/admin/servers/getServers.ts @@ -47,9 +47,9 @@ export interface Server { updatedAt: Date; relations: { - egg: Egg | undefined; - node: Node | undefined; - user: User | undefined; + egg?: Egg; + node?: Node; + user?: User; } } @@ -94,11 +94,11 @@ export const rawDataToServer = ({ attributes }: FractalResponseData): Server => updatedAt: new Date(attributes.updated_at), relations: { - egg: attributes.relationships?.egg !== undefined ? rawDataToEgg(attributes.relationships.egg as FractalResponseData) : undefined, - node: attributes.relationships?.node !== undefined ? rawDataToNode(attributes.relationships.node as FractalResponseData) : undefined, - user: attributes.relationships?.user !== undefined ? rawDataToUser(attributes.relationships.user as FractalResponseData) : undefined, + 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, }, -}); +}) as Server; export interface Filters { id?: string; diff --git a/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx index 92b3fecb7..eef598835 100644 --- a/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx +++ b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx @@ -1,14 +1,27 @@ +import AllocationTable from '@/components/admin/nodes/allocations/AllocationTable'; +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; import { useRouteMatch } from 'react-router-dom'; import AdminBox from '@/components/admin/AdminBox'; -import CreateAllocationForm from '@/components/admin/nodes/CreateAllocationForm'; +import CreateAllocationForm from '@/components/admin/nodes/allocations/CreateAllocationForm'; +import tw from 'twin.macro'; export default () => { const match = useRouteMatch<{ id: string }>(); return ( - - - + <> +
+
+ +
+ +
+ + + +
+
+ ); }; diff --git a/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx new file mode 100644 index 000000000..57b4132dc --- /dev/null +++ b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx @@ -0,0 +1,160 @@ +import { AdminContext } from '@/state/admin'; +import React, { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; +import getAllocations, { Context as AllocationsContext, Filters } from '@/api/admin/nodes/allocations/getAllocations'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { ContentWrapper, Loading, NoItems, Pagination, TableBody, TableHead, TableHeader, useTableHooks } from '@/components/admin/AdminTable'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import useFlash from '@/plugins/useFlash'; + +function RowCheckbox ({ id }: { id: number }) { + const isChecked = AdminContext.useStoreState(state => state.allocations.selectedAllocations.indexOf(id) >= 0); + const appendSelectedAllocation = AdminContext.useStoreActions(actions => actions.allocations.appendSelectedAllocation); + const removeSelectedAllocation = AdminContext.useStoreActions(actions => actions.allocations.removeSelectedAllocation); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedAllocation(id); + } else { + removeSelectedAllocation(id); + } + }} + /> + ); +} + +interface Props { + nodeId: string; + filters?: Filters; +} + +function AllocationsTable ({ nodeId, filters }: Props) { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(AllocationsContext); + const { data: allocations, error, isValidating } = getAllocations(nodeId, [ 'server' ]); + + const length = allocations?.items?.length || 0; + + const setSelectedAllocations = AdminContext.useStoreActions(actions => actions.allocations.setSelectedAllocations); + const selectedAllocationLength = AdminContext.useStoreState(state => state.allocations.selectedAllocations.length); + + const onSelectAllClick = (e: React.ChangeEvent) => { + setSelectedAllocations(e.currentTarget.checked ? (allocations?.items?.map?.(allocation => allocation.id) || []) : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise((resolve) => { + if (query.length < 2) { + setFilters(filters || null); + } else { + setFilters({ ...filters, ip: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedAllocations([]); + }, [ page ]); + + useEffect(() => { + if (!error) { + clearFlashes('allocations'); + return; + } + + clearAndAddHttpError({ key: 'allocations', error }); + }, [ error ]); + + return ( + + + +
+ + + setSort('ip')}/> + + setSort('port')}/> + + + + + { allocations !== undefined && !error && !isValidating && length > 0 && + allocations.items.map(allocation => ( + + + + + + {allocation.alias !== null ? + + : + + + {allocation.relations.server !== undefined ? + + : + + )) + } + +
+ + + + {allocation.ip} + + + + {allocation.alias} + + + } + + + + {allocation.port} + + + + {allocation.relations.server.name} + + + } +
+ + { allocations === undefined || (error && isValidating) ? + + : + length < 1 ? + + : + null + } +
+
+
+
+ ); +} + +export default (props: Props) => { + const hooks = useTableHooks(props.filters); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/CreateAllocationForm.tsx b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx similarity index 78% rename from resources/scripts/components/admin/nodes/CreateAllocationForm.tsx rename to resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx index 3da5ac7c2..55cf714c2 100644 --- a/resources/scripts/components/admin/nodes/CreateAllocationForm.tsx +++ b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx @@ -1,14 +1,18 @@ +import createAllocation from '@/api/admin/nodes/allocations/createAllocation'; +import Field from '@/components/elements/Field'; import { Form, Formik, FormikHelpers } from 'formik'; import React, { useEffect, useState } from 'react'; import tw from 'twin.macro'; import { array, number, object, string } from 'yup'; import getAllocations from '@/api/admin/nodes/getAllocations'; +import getAllocations2 from '@/api/admin/nodes/allocations/getAllocations'; import Button from '@/components/elements/Button'; import SelectField, { Option } from '@/components/elements/SelectField'; interface Values { ips: string[]; ports: number[]; + alias: string; } const distinct = (value: any, index: any, self: any) => { @@ -19,6 +23,8 @@ function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { const [ ips, setIPs ] = useState([]); const [ ports ] = useState([]); + const { mutate } = getAllocations2(nodeId, [ 'server' ]); + useEffect(() => { getAllocations(nodeId) .then(allocations => { @@ -40,6 +46,11 @@ function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { const submit = (values: Values, { setSubmitting }: FormikHelpers) => { setSubmitting(false); + + values.ips.forEach(async (ip) => { + const allocations = await createAllocation(nodeId, { ip, ports: values.ports, alias: values.alias }, [ 'server' ]); + await mutate(data => ({ ...data!, items: { ...data!.items!, ...allocations } })); + }); }; return ( @@ -48,6 +59,7 @@ function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { initialValues={{ ips: [] as string[], ports: [] as number[], + alias: '', }} validationSchema={object().shape({ ips: array(string()).min(1, 'You must select at least one ip address.'), @@ -80,6 +92,15 @@ function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { isCreatable /> +
+ +
+