diff --git a/.babel-plugin-macrosrc.js b/.babel-plugin-macrosrc.js new file mode 100644 index 000000000..fad1194f8 --- /dev/null +++ b/.babel-plugin-macrosrc.js @@ -0,0 +1,12 @@ +module.exports = { + twin: { + preset: 'styled-components', + autoCssProp: true, + config: './tailwind.config.js', + }, + styledComponents: { + pure: true, + displayName: false, + fileName: false, + }, +}; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..61df3a539 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,87 @@ +name: "Release" + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12' + + - name: Create release branch and bump version + env: + REF: ${{ github.ref }} + run: | + BRANCH=release/${REF:10} + git config --local user.email "ci@pterodactyl.io" + git config --local user.name "Pterodactyl CI" + git checkout -b $BRANCH + git push -u origin $BRANCH + sed -i "s/ 'version' => 'canary',/ 'version' => '${REF:11}',/" config/app.php + git add config/app.php + git commit -m "bump version for release" + git push + + - name: Build assets + run: | + yarn install + yarn run build:production + + - name: Create release archive + run: | + rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile + tar -czf panel.tar.gz * + + - name: Extract changelog + id: extract_changelog + env: + REF: ${{ github.ref }} + run: | + sed -n "/^## ${REF:10}/,/^## /{/^## /b;p}" CHANGELOG.md > ./RELEASE_CHANGELOG + echo ::set-output name=version_name::`sed -nr "s/^## (${REF:10} .*)$/\1/p" CHANGELOG.md` + + - name: Create checksum and add to changelog + run: | + SUM=`sha256sum panel.tar.gz` + echo -e "\n#### SHA256 Checksum\n\n\`\`\`\n$SUM\n\`\`\`\n" >> ./RELEASE_CHANGELOG + echo $SUM > checksum.txt + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ steps.extract_changelog.outputs.version_name }} + body_path: ./RELEASE_CHANGELOG + draft: true + prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} + + - name: Upload binary + id: upload-release-archive + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: panel.tar.gz + asset_name: panel.tar.gz + asset_content_type: application/gzip + + - name: Upload checksum + id: upload-release-checksum + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./checksum.txt + asset_name: checksum.txt + asset_content_type: text/plain diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 71328ff02..00c37cc59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,9 @@ name: tests on: push: + branch-ignore: + - 'master' + - 'release/**' pull_request: jobs: integration_tests: diff --git a/app/Contracts/Repository/AllocationRepositoryInterface.php b/app/Contracts/Repository/AllocationRepositoryInterface.php index 6f038a853..b02ef66a4 100644 --- a/app/Contracts/Repository/AllocationRepositoryInterface.php +++ b/app/Contracts/Repository/AllocationRepositoryInterface.php @@ -3,36 +3,9 @@ namespace Pterodactyl\Contracts\Repository; use Illuminate\Support\Collection; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; interface AllocationRepositoryInterface extends RepositoryInterface { - /** - * Set an array of allocation IDs to be assigned to a specific server. - * - * @param int|null $server - * @param array $ids - * @return int - */ - public function assignAllocationsToServer(int $server = null, array $ids): int; - - /** - * Return all of the allocations for a specific node. - * - * @param int $node - * @return \Illuminate\Support\Collection - */ - public function getAllocationsForNode(int $node): Collection; - - /** - * Return all of the allocations for a node in a paginated format. - * - * @param int $node - * @param int $perPage - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator - */ - public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator; - /** * Return all of the unique IPs that exist for a given node. * diff --git a/app/Contracts/Repository/ServerRepositoryInterface.php b/app/Contracts/Repository/ServerRepositoryInterface.php index 8851c5798..13a931764 100644 --- a/app/Contracts/Repository/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/ServerRepositoryInterface.php @@ -2,7 +2,6 @@ namespace Pterodactyl\Contracts\Repository; -use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Illuminate\Support\Collection; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -107,16 +106,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter */ public function getDaemonServiceData(Server $server, bool $refresh = false): array; - /** - * Return a paginated list of servers that a user can access at a given level. - * - * @param \Pterodactyl\Models\User $user - * @param int $level - * @param bool|int $paginate - * @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Collection - */ - public function filterUserAccessServers(User $user, int $level, $paginate = 25); - /** * Return a server by UUID. * diff --git a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php index cc1a32ff8..01ec37fe1 100644 --- a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php @@ -3,11 +3,10 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Nodes; use Pterodactyl\Models\Node; -use Illuminate\Http\Response; +use Illuminate\Http\JsonResponse; use Pterodactyl\Models\Allocation; use Pterodactyl\Services\Allocations\AssignmentService; use Pterodactyl\Services\Allocations\AllocationDeletionService; -use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; use Pterodactyl\Transformers\Api\Application\AllocationTransformer; use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController; use Pterodactyl\Http\Requests\Api\Application\Allocations\GetAllocationsRequest; @@ -26,41 +25,32 @@ class AllocationController extends ApplicationApiController */ private $deletionService; - /** - * @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface - */ - private $repository; - /** * AllocationController constructor. * * @param \Pterodactyl\Services\Allocations\AssignmentService $assignmentService * @param \Pterodactyl\Services\Allocations\AllocationDeletionService $deletionService - * @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $repository */ public function __construct( AssignmentService $assignmentService, - AllocationDeletionService $deletionService, - AllocationRepositoryInterface $repository + AllocationDeletionService $deletionService ) { parent::__construct(); $this->assignmentService = $assignmentService; $this->deletionService = $deletionService; - $this->repository = $repository; } /** * Return all of the allocations that exist for a given node. * * @param \Pterodactyl\Http\Requests\Api\Application\Allocations\GetAllocationsRequest $request + * @param \Pterodactyl\Models\Node $node * @return array */ - public function index(GetAllocationsRequest $request): array + public function index(GetAllocationsRequest $request, Node $node): array { - $allocations = $this->repository->getPaginatedAllocationsForNode( - $request->getModel(Node::class)->id, 50 - ); + $allocations = $node->allocations()->paginate(50); return $this->fractal->collection($allocations) ->transformWith($this->getTransformer(AllocationTransformer::class)) @@ -71,32 +61,35 @@ class AllocationController extends ApplicationApiController * Store new allocations for a given node. * * @param \Pterodactyl\Http\Requests\Api\Application\Allocations\StoreAllocationRequest $request - * @return \Illuminate\Http\Response + * @param \Pterodactyl\Models\Node $node + * @return \Illuminate\Http\JsonResponse * * @throws \Pterodactyl\Exceptions\Service\Allocation\CidrOutOfRangeException * @throws \Pterodactyl\Exceptions\Service\Allocation\InvalidPortMappingException * @throws \Pterodactyl\Exceptions\Service\Allocation\PortOutOfRangeException * @throws \Pterodactyl\Exceptions\Service\Allocation\TooManyPortsInRangeException */ - public function store(StoreAllocationRequest $request): Response + public function store(StoreAllocationRequest $request, Node $node): JsonResponse { - $this->assignmentService->handle($request->getModel(Node::class), $request->validated()); + $this->assignmentService->handle($node, $request->validated()); - return response('', 204); + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } /** * Delete a specific allocation from the Panel. * * @param \Pterodactyl\Http\Requests\Api\Application\Allocations\DeleteAllocationRequest $request - * @return \Illuminate\Http\Response + * @param \Pterodactyl\Models\Node $node + * @param \Pterodactyl\Models\Allocation $allocation + * @return \Illuminate\Http\JsonResponse * * @throws \Pterodactyl\Exceptions\Service\Allocation\ServerUsingAllocationException */ - public function delete(DeleteAllocationRequest $request): Response + public function delete(DeleteAllocationRequest $request, Node $node, Allocation $allocation): JsonResponse { - $this->deletionService->handle($request->getModel(Allocation::class)); + $this->deletionService->handle($allocation); - return response('', 204); + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } } diff --git a/app/Http/Controllers/Api/Client/ClientApiController.php b/app/Http/Controllers/Api/Client/ClientApiController.php index 7e3046179..56c3db1f7 100644 --- a/app/Http/Controllers/Api/Client/ClientApiController.php +++ b/app/Http/Controllers/Api/Client/ClientApiController.php @@ -10,6 +10,40 @@ use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController; abstract class ClientApiController extends ApplicationApiController { + /** + * Returns only the includes which are valid for the given transformer. + * + * @param \Pterodactyl\Transformers\Api\Client\BaseClientTransformer $transformer + * @param array $merge + * @return string[] + */ + protected function getIncludesForTransformer(BaseClientTransformer $transformer, array $merge = []) + { + $filtered = array_filter($this->parseIncludes(), function ($datum) use ($transformer) { + return in_array($datum, $transformer->getAvailableIncludes()); + }); + + return array_merge($filtered, $merge); + } + + /** + * Returns the parsed includes for this request. + * + * @return string[] + */ + protected function parseIncludes() + { + $includes = $this->request->query('include') ?? []; + + if (! is_string($includes)) { + return $includes; + } + + return array_map(function ($item) { + return trim($item); + }, explode(',', $includes)); + } + /** * Return an instance of an application transformer. * diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index b673ac5bf..300770aa5 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -3,7 +3,9 @@ namespace Pterodactyl\Http\Controllers\Api\Client; use Pterodactyl\Models\User; +use Pterodactyl\Models\Server; use Pterodactyl\Models\Permission; +use Spatie\QueryBuilder\QueryBuilder; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Transformers\Api\Client\ServerTransformer; use Pterodactyl\Http\Requests\Api\Client\GetServersRequest; @@ -36,32 +38,36 @@ class ClientController extends ClientApiController */ public function index(GetServersRequest $request): array { - // Check for the filter parameter on the request. - switch ($request->input('filter')) { - case 'all': - $filter = User::FILTER_LEVEL_ALL; - break; - case 'admin': - $filter = User::FILTER_LEVEL_ADMIN; - break; - case 'owner': - $filter = User::FILTER_LEVEL_OWNER; - break; - case 'subuser-of': - default: - $filter = User::FILTER_LEVEL_SUBUSER; - break; + $user = $request->user(); + $level = $request->getFilterLevel(); + $transformer = $this->getTransformer(ServerTransformer::class); + + // Start the query builder and ensure we eager load any requested relationships from the request. + $builder = Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])); + + if ($level === User::FILTER_LEVEL_OWNER) { + $builder = $builder->where('owner_id', $request->user()->id); + } + // If set to all, display all servers they can access, including those they access as an + // admin. If set to subuser, only return the servers they can access because they are owner, + // or marked as a subuser of the server. + elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) { + $builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); + } + // If set to admin, only display the servers a user can access because they are an administrator. + // This means only servers the user would not have access to if they were not an admin (because they + // are not an owner or subuser) are returned. + elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) { + $builder = $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()); } - $servers = $this->repository - ->setSearchTerm($request->input('query')) - ->filterUserAccessServers( - $request->user(), $filter, config('pterodactyl.paginate.frontend.servers') - ); + $builder = QueryBuilder::for($builder)->allowedFilters( + 'uuid', 'name', 'external_id' + ); - return $this->fractal->collection($servers) - ->transformWith($this->getTransformer(ServerTransformer::class)) - ->toArray(); + $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); + + return $this->fractal->transformWith($transformer)->collection($servers)->toArray(); } /** diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 4e916974d..55ebde5f2 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -159,7 +159,7 @@ class FileController extends ClientApiController { $this->fileRepository ->setServer($server) - ->createDirectory($request->input('name'), $request->input('directory', '/')); + ->createDirectory($request->input('name'), $request->input('root', '/')); return Response::create('', Response::HTTP_NO_CONTENT); } diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php new file mode 100644 index 000000000..162d815d9 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php @@ -0,0 +1,127 @@ +repository = $repository; + $this->serverRepository = $serverRepository; + } + + /** + * Lists all of the allocations available to a server and wether or + * not they are currently assigned as the primary for this server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + */ + public function index(GetNetworkRequest $request, Server $server): array + { + return $this->fractal->collection($server->allocations) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Set the primary allocation for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\UpdateAllocationRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Allocation $allocation + * @return array + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateAllocationRequest $request, Server $server, Allocation $allocation): array + { + $allocation = $this->repository->update($allocation->id, [ + 'notes' => $request->input('notes'), + ]); + + return $this->fractal->item($allocation) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Set the primary allocation for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\SetPrimaryAllocationRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Allocation $allocation + * @return array + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, Allocation $allocation): array + { + $this->serverRepository->update($server->id, ['allocation_id' => $allocation->id]); + + return $this->fractal->item($allocation) + ->transformWith($this->getTransformer(AllocationTransformer::class)) + ->toArray(); + } + + /** + * Delete an allocation from a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\DeleteAllocationRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Allocation $allocation + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function delete(DeleteAllocationRequest $request, Server $server, Allocation $allocation) + { + if ($allocation->id === $server->allocation_id) { + throw new DisplayException( + 'Cannot delete the primary allocation for a server.' + ); + } + + $this->repository->update($allocation->id, ['server_id' => null, 'notes' => null]); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkController.php b/app/Http/Controllers/Api/Client/Servers/NetworkController.php deleted file mode 100644 index fbea03033..000000000 --- a/app/Http/Controllers/Api/Client/Servers/NetworkController.php +++ /dev/null @@ -1,48 +0,0 @@ -repository = $repository; - } - - /** - * Lists all of the allocations available to a server and wether or - * not they are currently assigned as the primary for this server. - * - * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Network\GetNetworkRequest $request - * @param \Pterodactyl\Models\Server $server - * @return array - */ - public function index(GetNetworkRequest $request, Server $server): array - { - $allocations = $this->repository->findWhere([ - ['server_id', '=', $server->id], - ]); - - return $this->fractal->collection($allocations) - ->transformWith($this->getTransformer(AllocationTransformer::class)) - ->toArray(); - } -} diff --git a/app/Http/Controllers/Base/IndexController.php b/app/Http/Controllers/Base/IndexController.php index f62de118e..41ff988f7 100644 --- a/app/Http/Controllers/Base/IndexController.php +++ b/app/Http/Controllers/Base/IndexController.php @@ -2,8 +2,6 @@ namespace Pterodactyl\Http\Controllers\Base; -use Illuminate\Http\Request; -use Pterodactyl\Models\User; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; @@ -27,15 +25,10 @@ class IndexController extends Controller /** * Returns listing of user's servers. * - * @param \Illuminate\Http\Request $request * @return \Illuminate\View\View */ - public function index(Request $request) + public function index() { - $servers = $this->repository->setSearchTerm($request->input('query'))->filterUserAccessServers( - $request->user(), User::FILTER_LEVEL_ALL, config('pterodactyl.paginate.frontend.servers') - ); - - return view('templates/base.core', ['servers' => $servers]); + return view('templates/base.core'); } } diff --git a/app/Http/Middleware/Api/Client/Server/AllocationBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/AllocationBelongsToServer.php new file mode 100644 index 000000000..d027d563c --- /dev/null +++ b/app/Http/Middleware/Api/Client/Server/AllocationBelongsToServer.php @@ -0,0 +1,33 @@ +route()->parameter('server'); + /** @var \Pterodactyl\Models\Allocation|null $allocation */ + $allocation = $request->route()->parameter('allocation'); + + if ($allocation && $allocation->server_id !== $server->id) { + throw new NotFoundHttpException; + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php index 1525755cf..8c10b22d2 100644 --- a/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php +++ b/app/Http/Middleware/Api/Client/Server/AuthenticateServerAccess.php @@ -65,7 +65,7 @@ class AuthenticateServerAccess } if ($server->suspended) { - throw new AccessDeniedHttpException('This server is currenty suspended and the functionality requested is unavailable.'); + throw new AccessDeniedHttpException('This server is currently suspended and the functionality requested is unavailable.'); } if (! $server->isInstalled()) { diff --git a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php index 81ed6b401..0bd40eee5 100644 --- a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php +++ b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php @@ -4,12 +4,12 @@ namespace Pterodactyl\Http\Middleware\Api\Client; use Closure; use Pterodactyl\Models\Backup; +use Pterodactyl\Models\Database; use Illuminate\Container\Container; use Pterodactyl\Contracts\Extensions\HashidsInterface; use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; -use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface; class SubstituteClientApiBindings extends ApiSubstituteBindings { @@ -43,17 +43,9 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings }); $this->router->bind('database', function ($value) use ($request) { - try { - $id = Container::getInstance()->make(HashidsInterface::class)->decodeFirst($value); + $id = Container::getInstance()->make(HashidsInterface::class)->decodeFirst($value); - return Container::getInstance()->make(DatabaseRepositoryInterface::class)->findFirstWhere([ - ['id', '=', $id], - ]); - } catch (RecordNotFoundException $exception) { - $request->attributes->set('is_missing_model', true); - - return null; - } + return Database::query()->where('id', $id)->firstOrFail(); }); $this->router->model('backup', Backup::class, function ($value) { diff --git a/app/Http/Requests/Api/Client/GetServersRequest.php b/app/Http/Requests/Api/Client/GetServersRequest.php index 9b4601f25..c28f0a946 100644 --- a/app/Http/Requests/Api/Client/GetServersRequest.php +++ b/app/Http/Requests/Api/Client/GetServersRequest.php @@ -2,6 +2,8 @@ namespace Pterodactyl\Http\Requests\Api\Client; +use Pterodactyl\Models\User; + class GetServersRequest extends ClientApiRequest { /** @@ -11,4 +13,28 @@ class GetServersRequest extends ClientApiRequest { return true; } + + /** + * Return the filtering method for servers when the client base endpoint is requested. + * + * @return int + */ + public function getFilterLevel(): int + { + switch ($this->input('type')) { + case 'all': + return User::FILTER_LEVEL_ALL; + break; + case 'admin': + return User::FILTER_LEVEL_ADMIN; + break; + case 'owner': + return User::FILTER_LEVEL_OWNER; + break; + case 'subuser-of': + default: + return User::FILTER_LEVEL_SUBUSER; + break; + } + } } diff --git a/app/Http/Requests/Api/Client/Servers/Network/DeleteAllocationRequest.php b/app/Http/Requests/Api/Client/Servers/Network/DeleteAllocationRequest.php new file mode 100644 index 000000000..9c0d911f0 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Network/DeleteAllocationRequest.php @@ -0,0 +1,17 @@ + array_merge($rules['notes'], ['present']), + ]; + } +} diff --git a/app/Http/ViewComposers/Server/ServerDataComposer.php b/app/Http/ViewComposers/Server/ServerDataComposer.php deleted file mode 100644 index 9e1858645..000000000 --- a/app/Http/ViewComposers/Server/ServerDataComposer.php +++ /dev/null @@ -1,38 +0,0 @@ -request = $request; - } - - /** - * Attach server data to a view automatically. - * - * @param \Illuminate\View\View $view - */ - public function compose(View $view) - { - $server = $this->request->get('server'); - - $view->with('server', $server); - $view->with('node', object_get($server, 'node')); - $view->with('daemon_token', $this->request->get('server_token')); - } -} diff --git a/app/Http/ViewComposers/ServerListComposer.php b/app/Http/ViewComposers/ServerListComposer.php deleted file mode 100644 index 9b57884aa..000000000 --- a/app/Http/ViewComposers/ServerListComposer.php +++ /dev/null @@ -1,51 +0,0 @@ -request = $request; - $this->repository = $repository; - } - - /** - * Attach a list of servers the user can access to the view. - * - * @param \Illuminate\View\View $view - */ - public function compose(View $view) - { - if (! $this->request->user()) { - return; - } - - $servers = $this->repository - ->setColumns(['id', 'owner_id', 'uuidShort', 'name', 'description']) - ->filterUserAccessServers($this->request->user(), User::FILTER_LEVEL_SUBUSER, false); - - $view->with('sidebarServerList', $servers); - } -} diff --git a/app/Models/Allocation.php b/app/Models/Allocation.php index 5f2435624..81e596523 100644 --- a/app/Models/Allocation.php +++ b/app/Models/Allocation.php @@ -9,6 +9,7 @@ namespace Pterodactyl\Models; * @property string|null $ip_alias * @property int $port * @property int|null $server_id + * @property string|null $notes * @property \Carbon\Carbon|null $created_at * @property \Carbon\Carbon|null $updated_at * @@ -60,6 +61,7 @@ class Allocation extends Model 'port' => 'required|numeric|between:1024,65553', 'ip_alias' => 'nullable|string', 'server_id' => 'nullable|exists:servers,id', + 'notes' => 'nullable|string|max:256', ]; /** diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 772ae5e49..af3dc5cf9 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -44,7 +44,9 @@ class Permission extends Model const ACTION_BACKUP_DOWNLOAD = 'backup.download'; const ACTION_ALLOCATION_READ = 'allocation.read'; - const ACTION_ALLOCIATION_UPDATE = 'allocation.update'; + const ACTION_ALLOCATION_CREATE = 'allocation.create'; + const ACTION_ALLOCATION_UPDATE = 'allocation.update'; + const ACTION_ALLOCATION_DELETE = 'allocation.delete'; const ACTION_FILE_READ = 'file.read'; const ACTION_FILE_CREATE = 'file.create'; @@ -157,7 +159,9 @@ class Permission extends Model 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', 'keys' => [ 'read' => 'Allows a user to view the allocations assigned to this server.', - 'update' => 'Allows a user to modify the allocations assigned to this server.', + 'create' => 'Allows a user to assign additional allocations to the server.', + 'update' => 'Allows a user to change the primary server allocation and attach notes to each allocation.', + 'delete' => 'Allows a user to delete an allocation from the server.', ], ], diff --git a/app/Models/RecoveryToken.php b/app/Models/RecoveryToken.php index 7be74f53c..5cd00a9d0 100644 --- a/app/Models/RecoveryToken.php +++ b/app/Models/RecoveryToken.php @@ -17,6 +17,11 @@ class RecoveryToken extends Model */ const UPDATED_AT = null; + /** + * @var bool + */ + public $timestamps = true; + /** * @var bool */ diff --git a/app/Models/User.php b/app/Models/User.php index 408ceead3..baff65b6f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Illuminate\Validation\Rules\In; use Illuminate\Auth\Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Database\Eloquent\Builder; use Pterodactyl\Models\Traits\Searchable; use Illuminate\Auth\Passwords\CanResetPassword; use Pterodactyl\Traits\Helpers\AvailableLanguages; @@ -260,4 +261,21 @@ class User extends Model implements { return $this->hasMany(RecoveryToken::class); } + + /** + * Returns all of the servers that a user can access by way of being the owner of the + * server, or because they are assigned as a subuser for that server. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function accessibleServers() + { + return Server::query() + ->select('servers.*') + ->leftJoin('subusers', 'subusers.server_id', '=', 'servers.id') + ->where(function (Builder $builder) { + $builder->where('servers.owner_id', $this->id)->orWhere('subusers.user_id', $this->id); + }) + ->groupBy('servers.id'); + } } diff --git a/app/Providers/ViewComposerServiceProvider.php b/app/Providers/ViewComposerServiceProvider.php index 9490234f3..9f484e006 100644 --- a/app/Providers/ViewComposerServiceProvider.php +++ b/app/Providers/ViewComposerServiceProvider.php @@ -4,8 +4,6 @@ namespace Pterodactyl\Providers; use Illuminate\Support\ServiceProvider; use Pterodactyl\Http\ViewComposers\AssetComposer; -use Pterodactyl\Http\ViewComposers\ServerListComposer; -use Pterodactyl\Http\ViewComposers\Server\ServerDataComposer; class ViewComposerServiceProvider extends ServiceProvider { @@ -15,10 +13,5 @@ class ViewComposerServiceProvider extends ServiceProvider public function boot() { $this->app->make('view')->composer('*', AssetComposer::class); - - $this->app->make('view')->composer('server.*', ServerDataComposer::class); - - // Add data to make the sidebar work when viewing a server. - $this->app->make('view')->composer(['server.*'], ServerListComposer::class); } } diff --git a/app/Repositories/Eloquent/AllocationRepository.php b/app/Repositories/Eloquent/AllocationRepository.php index 920b5c135..cc7efb23b 100644 --- a/app/Repositories/Eloquent/AllocationRepository.php +++ b/app/Repositories/Eloquent/AllocationRepository.php @@ -5,7 +5,6 @@ namespace Pterodactyl\Repositories\Eloquent; use Illuminate\Support\Collection; use Pterodactyl\Models\Allocation; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface; class AllocationRepository extends EloquentRepository implements AllocationRepositoryInterface @@ -20,41 +19,6 @@ class AllocationRepository extends EloquentRepository implements AllocationRepos return Allocation::class; } - /** - * Set an array of allocation IDs to be assigned to a specific server. - * - * @param int|null $server - * @param array $ids - * @return int - */ - public function assignAllocationsToServer(int $server = null, array $ids): int - { - return $this->getBuilder()->whereIn('id', $ids)->update(['server_id' => $server]); - } - - /** - * Return all of the allocations for a specific node. - * - * @param int $node - * @return \Illuminate\Support\Collection - */ - public function getAllocationsForNode(int $node): Collection - { - return $this->getBuilder()->where('node_id', $node)->get($this->getColumns()); - } - - /** - * Return all of the allocations for a node in a paginated format. - * - * @param int $node - * @param int $perPage - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator - */ - public function getPaginatedAllocationsForNode(int $node, int $perPage = 100): LengthAwarePaginator - { - return $this->getBuilder()->where('node_id', $node)->paginate($perPage, $this->getColumns()); - } - /** * Return all of the unique IPs that exist for a given node. * diff --git a/app/Repositories/Eloquent/EloquentRepository.php b/app/Repositories/Eloquent/EloquentRepository.php index 73e671cc6..34b5247af 100644 --- a/app/Repositories/Eloquent/EloquentRepository.php +++ b/app/Repositories/Eloquent/EloquentRepository.php @@ -2,9 +2,11 @@ namespace Pterodactyl\Repositories\Eloquent; +use Illuminate\Http\Request; use Webmozart\Assert\Assert; use Illuminate\Support\Collection; use Pterodactyl\Repositories\Repository; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Expression; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -15,6 +17,53 @@ use Pterodactyl\Contracts\Repository\Attributes\SearchableInterface; abstract class EloquentRepository extends Repository implements RepositoryInterface { + /** + * @var bool + */ + protected $useRequestFilters = false; + + /** + * Determines if the repository function should use filters off the request object + * present when returning results. This allows repository methods to be called in API + * context's such that we can pass through ?filter[name]=Dane&sort=desc for example. + * + * @param bool $usingFilters + * @return $this + */ + public function usingRequestFilters($usingFilters = true) + { + $this->useRequestFilters = $usingFilters; + + return $this; + } + + /** + * Returns the request instance. + * + * @return \Illuminate\Http\Request + */ + protected function request() + { + return $this->app->make(Request::class); + } + + /** + * Paginate the response data based on the page para. + * + * @param \Illuminate\Database\Eloquent\Builder $instance + * @param int $default + * + * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + */ + protected function paginate(Builder $instance, int $default = 50) + { + if (! $this->useRequestFilters) { + return $instance->paginate($default); + } + + return $instance->paginate($this->request()->query('per_page', $default)); + } + /** * Return an instance of the eloquent model bound to this * repository instance. @@ -236,6 +285,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf * Return all records associated with the given model. * * @return \Illuminate\Support\Collection + * @deprecated Just use the model */ public function all(): Collection { @@ -313,6 +363,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf * Get the amount of entries in the database. * * @return int + * @deprecated just use the count method off a model */ public function count(): int { diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index e3e398d5e..a64f68db9 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -2,7 +2,6 @@ namespace Pterodactyl\Repositories\Eloquent; -use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Builder; @@ -226,43 +225,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt ]; } - /** - * Return a paginated list of servers that a user can access at a given level. - * - * @param \Pterodactyl\Models\User $user - * @param int $level - * @param bool|int $paginate - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator|\Illuminate\Database\Eloquent\Collection - */ - public function filterUserAccessServers(User $user, int $level, $paginate = 25) - { - $instance = $this->getBuilder()->select($this->getColumns())->with(['user', 'node', 'allocation']); - - // If access level is set to owner, only display servers - // that the user owns. - if ($level === User::FILTER_LEVEL_OWNER) { - $instance->where('owner_id', $user->id); - } - - // If set to all, display all servers they can access, including - // those they access as an admin. If set to subuser, only return - // the servers they can access because they are owner, or marked - // as a subuser of the server. - elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) { - $instance->whereIn('id', $this->getUserAccessServers($user->id)); - } - - // If set to admin, only display the servers a user can access - // as an administrator (leaves out owned and subuser of). - elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) { - $instance->whereNotIn('id', $this->getUserAccessServers($user->id)); - } - - $instance->search($this->getSearchTerm()); - - return $paginate ? $instance->paginate($paginate) : $instance->get(); - } - /** * Return a server by UUID. * @@ -339,20 +301,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt return ! $this->getBuilder()->where('uuid', '=', $uuid)->orWhere('uuidShort', '=', $short)->exists(); } - /** - * Return an array of server IDs that a given user can access based - * on owner and subuser permissions. - * - * @param int $user - * @return int[] - */ - private function getUserAccessServers(int $user): array - { - return $this->getBuilder()->select('id')->where('owner_id', $user)->union( - $this->app->make(SubuserRepository::class)->getBuilder()->select('server_id')->where('user_id', $user) - )->pluck('id')->all(); - } - /** * Get the amount of servers that are suspended. * diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index b84bed7c5..09638547b 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -270,7 +270,9 @@ class ServerCreationService $records = array_merge($records, $data['allocation_additional']); } - $this->allocationRepository->assignAllocationsToServer($server->id, $records); + $this->allocationRepository->updateWhereIn('id', $records, [ + 'server_id' => $server->id, + ]); } /** diff --git a/app/Transformers/Api/Application/AllocationTransformer.php b/app/Transformers/Api/Application/AllocationTransformer.php index e4fa1bb57..d0c71e634 100644 --- a/app/Transformers/Api/Application/AllocationTransformer.php +++ b/app/Transformers/Api/Application/AllocationTransformer.php @@ -39,6 +39,7 @@ class AllocationTransformer extends BaseTransformer 'ip' => $allocation->ip, 'alias' => $allocation->ip_alias, 'port' => $allocation->port, + 'notes' => $allocation->notes, 'assigned' => ! is_null($allocation->server_id), ]; } diff --git a/app/Transformers/Api/Client/AllocationTransformer.php b/app/Transformers/Api/Client/AllocationTransformer.php index 055afdae3..57f72eaf3 100644 --- a/app/Transformers/Api/Client/AllocationTransformer.php +++ b/app/Transformers/Api/Client/AllocationTransformer.php @@ -24,13 +24,13 @@ class AllocationTransformer extends BaseClientTransformer */ public function transform(Allocation $model) { - $model->loadMissing('server'); - return [ + 'id' => $model->id, 'ip' => $model->ip, - 'alias' => $model->ip_alias, + 'ip_alias' => $model->ip_alias, 'port' => $model->port, - 'default' => $model->getRelation('server')->allocation_id === $model->id, + 'notes' => $model->notes, + 'is_default' => $model->server->allocation_id === $model->id, ]; } } diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 50d45e276..148fd8990 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -5,9 +5,15 @@ namespace Pterodactyl\Transformers\Api\Client; use Pterodactyl\Models\Egg; use Pterodactyl\Models\Server; use Pterodactyl\Models\Subuser; +use Pterodactyl\Models\Allocation; class ServerTransformer extends BaseClientTransformer { + /** + * @var string[] + */ + protected $defaultIncludes = ['allocations']; + /** * @var array */ @@ -41,10 +47,6 @@ class ServerTransformer extends BaseClientTransformer 'port' => $server->node->daemonSFTP, ], 'description' => $server->description, - 'allocation' => [ - 'ip' => $server->allocation->alias, - 'port' => $server->allocation->port, - ], 'limits' => [ 'memory' => $server->memory, 'swap' => $server->swap, @@ -62,6 +64,22 @@ class ServerTransformer extends BaseClientTransformer ]; } + /** + * Returns the allocations associated with this server. + * + * @param \Pterodactyl\Models\Server $server + * @return \League\Fractal\Resource\Collection + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeAllocations(Server $server) + { + return $this->collection( + $server->allocations, + $this->makeTransformer(AllocationTransformer::class), + Allocation::RESOURCE_NAME + ); + } + /** * Returns the egg associated with this server. * diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..a0ead0290 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,23 @@ +module.exports = { + presets: [ + '@babel/typescript', + ['@babel/env', { + modules: false, + useBuiltIns: 'entry', + corejs: 3, + }], + '@babel/react', + ], + plugins: [ + 'babel-plugin-macros', + 'styled-components', + 'react-hot-loader/babel', + '@babel/transform-runtime', + '@babel/transform-react-jsx', + '@babel/proposal-class-properties', + '@babel/proposal-object-rest-spread', + '@babel/proposal-optional-chaining', + '@babel/proposal-nullish-coalescing-operator', + '@babel/syntax-dynamic-import', + ], +}; diff --git a/composer.json b/composer.json index 0ccfab915..a07a9ae85 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "psy/psysh": "^0.10.4", "s1lentium/iptools": "^1.1", "spatie/laravel-fractal": "^5.7", + "spatie/laravel-query-builder": "^2.8", "staudenmeir/belongs-to-through": "^2.10", "symfony/yaml": "^4.4", "webmozart/assert": "^1.9" diff --git a/composer.lock b/composer.lock index 7593ee4b4..6df479deb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "155b8e930e604c0476fa975b1084ca3f", + "content-hash": "d05ab995e4aff4b847ff2a027924065c", "packages": [ { "name": "appstract/laravel-blade-directives", @@ -3361,6 +3361,70 @@ ], "time": "2020-03-02T18:40:49+00:00" }, + { + "name": "spatie/laravel-query-builder", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-query-builder.git", + "reference": "2737b2298e8bfeb632a80013646943307bf31775" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/2737b2298e8bfeb632a80013646943307bf31775", + "reference": "2737b2298e8bfeb632a80013646943307bf31775", + "shasum": "" + }, + "require": { + "illuminate/database": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0", + "illuminate/http": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0", + "illuminate/support": "~5.6.34|~5.7.0|~5.8.0|^6.0|^7.0", + "php": "^7.1" + }, + "require-dev": { + "ext-json": "*", + "orchestra/testbench": "~3.6.0|~3.7.0|~3.8.0|^4.0|^5.0", + "phpunit/phpunit": "^7.0|^8.0|^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\QueryBuilder\\QueryBuilderServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\QueryBuilder\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily build Eloquent queries from API requests", + "homepage": "https://github.com/spatie/laravel-query-builder", + "keywords": [ + "laravel-query-builder", + "spatie" + ], + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2020-05-25T09:36:37+00:00" + }, { "name": "staudenmeir/belongs-to-through", "version": "v2.10", diff --git a/database/migrations/2020_07_09_201845_add_notes_column_for_allocations.php b/database/migrations/2020_07_09_201845_add_notes_column_for_allocations.php new file mode 100644 index 000000000..ed71f9c24 --- /dev/null +++ b/database/migrations/2020_07_09_201845_add_notes_column_for_allocations.php @@ -0,0 +1,32 @@ +string('notes')->nullable()->after('server_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('allocations', function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +} diff --git a/package.json b/package.json index f66bd9a60..99bcf0d37 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { "name": "pterodactyl-panel", "dependencies": { - "@fortawesome/fontawesome-svg-core": "^1.2.19", + "@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/free-solid-svg-icons": "^5.9.0", - "@fortawesome/react-fontawesome": "^0.1.4", + "@fortawesome/react-fontawesome": "0.1.4", "@types/react-google-recaptcha": "^1.1.1", - "axios": "^0.19.0", + "axios": "^0.19.2", "ayu-ace": "^2.0.4", "brace": "^0.11.1", "chart.js": "^2.8.0", - "classnames": "^2.2.6", - "date-fns": "^1.29.0", - "easy-peasy": "^3.2.3", + "date-fns": "^2.14.0", + "debounce": "^1.2.0", + "deepmerge": "^4.2.2", + "easy-peasy": "^3.3.1", "events": "^3.0.0", "formik": "^2.1.4", "i18next": "^19.0.0", @@ -19,26 +20,26 @@ "i18next-localstorage-backend": "^3.0.0", "i18next-xhr-backend": "^3.2.2", "jquery": "^3.3.1", - "lodash-es": "^4.17.15", "path": "^0.12.7", "query-string": "^6.7.0", - "react": "^16.12.0", + "react": "^16.13.1", "react-dom": "npm:@hot-loader/react-dom", + "react-fast-compare": "^3.2.0", "react-google-recaptcha": "^2.0.1", - "react-hot-loader": "^4.12.18", + "react-hot-loader": "^4.12.21", "react-i18next": "^11.2.1", "react-redux": "^7.1.0", "react-router-dom": "^5.1.2", - "react-transition-group": "^4.3.0", + "react-transition-group": "^4.4.1", "sockette": "^2.0.6", - "styled-components": "^4.4.1", + "styled-components": "^5.1.1", "styled-components-breakpoint": "^3.0.0-preview.20", - "use-react-router": "^1.0.7", + "swr": "^0.2.3", "uuid": "^3.3.2", "xterm": "^3.14.4", "xterm-addon-attach": "^0.1.0", "xterm-addon-fit": "^0.1.0", - "yup": "^0.27.0" + "yup": "^0.29.1" }, "devDependencies": { "@babel/core": "^7.7.5", @@ -47,75 +48,65 @@ "@babel/plugin-proposal-object-rest-spread": "^7.7.4", "@babel/plugin-proposal-optional-chaining": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.7.4", + "@babel/plugin-transform-react-jsx": "^7.10.4", "@babel/plugin-transform-runtime": "^7.7.5", "@babel/preset-env": "^7.7.5", "@babel/preset-react": "^7.7.4", "@babel/preset-typescript": "^7.7.4", "@babel/runtime": "^7.7.5", "@types/chart.js": "^2.8.5", - "@types/classnames": "^2.2.8", + "@types/debounce": "^1.2.0", "@types/events": "^3.0.0", - "@types/feather-icons": "^4.7.0", - "@types/lodash": "^4.14.119", - "@types/lodash-es": "^4.17.3", "@types/node": "^12.6.9", "@types/query-string": "^6.3.0", - "@types/react": "^16.9.15", - "@types/react-dom": "^16.9.4", + "@types/react": "^16.9.41", + "@types/react-dom": "^16.9.8", "@types/react-redux": "^7.1.1", "@types/react-router": "^5.1.3", "@types/react-router-dom": "^5.1.3", - "@types/react-transition-group": "^2.9.2", - "@types/styled-components": "^4.4.0", + "@types/react-transition-group": "^4.4.0", + "@types/styled-components": "^5.1.0", "@types/uuid": "^3.4.5", - "@types/webpack-env": "^1.13.6", - "@types/yup": "^0.26.17", - "@typescript-eslint/eslint-plugin": "^2.19.0", - "@typescript-eslint/parser": "^2.19.0", + "@types/webpack-env": "^1.15.2", + "@types/yup": "^0.29.3", + "@typescript-eslint/eslint-plugin": "^3.5.0", + "@typescript-eslint/parser": "^3.5.0", "babel-loader": "^8.0.6", - "babel-plugin-styled-components": "^1.10.6", - "babel-plugin-tailwind-components": "^0.5.10", + "babel-plugin-styled-components": "^1.10.7", "cross-env": "^7.0.2", "css-loader": "^3.2.1", - "cssnano": "^4.1.10", - "eslint": "^5.16.0", - "eslint-config-standard": "^12.0.0", - "eslint-plugin-import": "^2.17.3", + "eslint": "^7.4.0", + "eslint-config-standard": "^14.1.1", + "eslint-plugin-import": "^2.22.0", "eslint-plugin-node": "^9.1.0", - "eslint-plugin-promise": "^4.1.1", - "eslint-plugin-react-hooks": "^2.1.2", - "eslint-plugin-standard": "^4.0.0", - "fork-ts-checker-webpack-plugin": "^1.5.0", - "glob-all": "^3.1.0", - "html-webpack-plugin": "^3.2.0", - "mini-css-extract-plugin": "^0.8.0", - "postcss": "^7.0.24", - "postcss-import": "^12.0.1", - "postcss-loader": "^3.0.0", - "postcss-preset-env": "^6.7.0", - "precss": "^4.0.0", - "purgecss-webpack-plugin": "^1.6.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-react": "^7.20.3", + "eslint-plugin-react-hooks": "^4.0.5", + "eslint-plugin-standard": "^4.0.1", + "fork-ts-checker-webpack-plugin": "^5.0.6", "redux-devtools-extension": "^2.13.8", - "resolve-url-loader": "^3.0.0", - "source-map-loader": "^0.2.4", - "style-loader": "^0.23.1", - "tailwindcss": "^0.7.4", - "terser-webpack-plugin": "^1.3.0", - "ts-loader": "^6.2.1", - "typescript": "^3.7.5", - "webpack": "^4.41.2", + "source-map-loader": "^1.0.1", + "style-loader": "^1.2.1", + "svg-url-loader": "^6.0.0", + "tailwindcss": "^1.4.6", + "terser-webpack-plugin": "^3.0.6", + "twin.macro": "^1.4.1", + "typescript": "^3.9.6", + "typescript-plugin-tw-template": "^2.0.1", + "webpack": "^4.43.0", "webpack-assets-manifest": "^3.1.1", - "webpack-cli": "^3.3.10", - "webpack-dev-server": "^3.9.0", - "webpack-manifest-plugin": "^2.0.3", + "webpack-bundle-analyzer": "^3.8.0", + "webpack-cli": "^3.3.12", + "webpack-dev-server": "^3.11.0", "yarn-deduplicate": "^1.1.1" }, "scripts": { - "clean": "rm -rf public/assets/*.{js,css,map}", + "clean": "cd public/assets && find . \\( -name \"*.js\" -o -name \"*.map\" \\) -type f -delete", + "lint": "eslint ./resources/scripts/**/*.{ts,tsx} --ext .ts,.tsx", "watch": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --watch --progress", "build": "cross-env NODE_ENV=development ./node_modules/.bin/webpack --progress", "build:production": "yarn run clean && cross-env NODE_ENV=production ./node_modules/.bin/webpack --mode production", - "serve": "yarn run clean && cross-env PUBLIC_PATH=https://pterodactyl.test:8080 NODE_ENV=development webpack-dev-server --host 0.0.0.0 --hot --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem" + "serve": "yarn run clean && cross-env PUBLIC_PATH=https://pterodactyl.test:8080 NODE_ENV=development TSC_WATCHFILE=UseFsEventsWithFallbackDynamicPolling webpack-dev-server --host 0.0.0.0 --hot --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem" }, "browserslist": [ "> 0.5%", diff --git a/resources/scripts/.eslintrc.yml b/resources/scripts/.eslintrc.yml index 564306640..0b0d552f6 100644 --- a/resources/scripts/.eslintrc.yml +++ b/resources/scripts/.eslintrc.yml @@ -1,38 +1,76 @@ parser: "@typescript-eslint/parser" parserOptions: ecmaVersion: 6 + ecmaFeatures: + jsx: true project: "./tsconfig.json" tsconfigRootDir: "./" +settings: + react: + pragma: "React" + version: "detect" + linkComponents: + - name: Link + linkAttribute: to + - name: NavLink + linkAttribute: to env: browser: true es6: true plugins: - - "@typescript-eslint" + - "react" - "react-hooks" + - "@typescript-eslint" extends: - "standard" + - "plugin:react/recommended" - "plugin:@typescript-eslint/recommended" -globals: - tw: "readonly" rules: indent: - error - 4 + - SwitchCase: 1 semi: - error - always comma-dangle: - error - always-multiline + array-bracket-spacing: + - warn + - always "react-hooks/rules-of-hooks": - error "react-hooks/exhaustive-deps": 0 "@typescript-eslint/explicit-function-return-type": 0 "@typescript-eslint/explicit-member-accessibility": 0 "@typescript-eslint/ban-ts-ignore": 0 - "@typescript-eslint/no-unused-vars": 0 "@typescript-eslint/no-explicit-any": 0 "@typescript-eslint/no-non-null-assertion": 0 + "@typescript-eslint/ban-ts-comment": 0 + # This would be nice to have, but don't want to deal with the warning spam at the moment. + "@typescript-eslint/explicit-module-boundary-types": 0 + no-restricted-imports: + - error + - paths: + - name: styled-components + message: Please import from styled-components/macro. + patterns: + - "!styled-components/macro" + # Not sure, this rule just doesn't work right and is protected by our use of Typescript anyways + # so I'm just not going to worry about it. + "react/prop-types": 0 + "react/display-name": 0 + "react/jsx-indent-props": + - warn + - 4 + "react/jsx-boolean-value": + - warn + - never + "react/jsx-closing-bracket-location": + - 1 + - "line-aligned" + "react/jsx-closing-tag-location": 1 overrides: - files: - "**/*.tsx" diff --git a/resources/scripts/TransitionRouter.tsx b/resources/scripts/TransitionRouter.tsx index 78fd164d1..227f7dcdc 100644 --- a/resources/scripts/TransitionRouter.tsx +++ b/resources/scripts/TransitionRouter.tsx @@ -1,21 +1,30 @@ import React from 'react'; import { Route } from 'react-router'; -import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { SwitchTransition } from 'react-transition-group'; +import Fade from '@/components/elements/Fade'; +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; -type Props = Readonly<{ - children: React.ReactNode; -}>; +const StyledSwitchTransition = styled(SwitchTransition)` + ${tw`relative`}; + + & section { + ${tw`absolute w-full top-0 left-0`}; + } +`; -export default ({ children }: Props) => ( +const TransitionRouter: React.FC = ({ children }) => ( ( - - + +
{children}
-
-
+ + )} /> ); + +export default TransitionRouter; diff --git a/resources/scripts/api/account/createApiKey.ts b/resources/scripts/api/account/createApiKey.ts index afe509264..7067ec145 100644 --- a/resources/scripts/api/account/createApiKey.ts +++ b/resources/scripts/api/account/createApiKey.ts @@ -3,13 +3,13 @@ import { ApiKey, rawDataToApiKey } from '@/api/account/getApiKeys'; export default (description: string, allowedIps: string): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/account/api-keys`, { + http.post('/api/client/account/api-keys', { description, - // eslint-disable-next-line @typescript-eslint/camelcase allowed_ips: allowedIps.length > 0 ? allowedIps.split('\n') : [], }) .then(({ data }) => resolve({ ...rawDataToApiKey(data.attributes), + // eslint-disable-next-line camelcase secretToken: data.meta?.secret_token ?? '', })) .catch(reject); diff --git a/resources/scripts/api/account/updateAccountPassword.ts b/resources/scripts/api/account/updateAccountPassword.ts index c29aefd2d..d59e85e9c 100644 --- a/resources/scripts/api/account/updateAccountPassword.ts +++ b/resources/scripts/api/account/updateAccountPassword.ts @@ -9,10 +9,8 @@ interface Data { export default ({ current, password, confirmPassword }: Data): Promise => { return new Promise((resolve, reject) => { http.put('/api/client/account/password', { - // eslint-disable-next-line @typescript-eslint/camelcase current_password: current, password: password, - // eslint-disable-next-line @typescript-eslint/camelcase password_confirmation: confirmPassword, }) .then(() => resolve()) diff --git a/resources/scripts/api/auth/loginCheckpoint.ts b/resources/scripts/api/auth/loginCheckpoint.ts index 25bb715a4..2d139fa52 100644 --- a/resources/scripts/api/auth/loginCheckpoint.ts +++ b/resources/scripts/api/auth/loginCheckpoint.ts @@ -4,11 +4,9 @@ import { LoginResponse } from '@/api/auth/login'; export default (token: string, code: string, recoveryToken?: string): Promise => { return new Promise((resolve, reject) => { http.post('/auth/login/checkpoint', { - /* eslint-disable @typescript-eslint/camelcase */ confirmation_token: token, authentication_code: code, recovery_token: (recoveryToken && recoveryToken.length > 0) ? recoveryToken : undefined, - /* eslint-enable @typescript-eslint/camelcase */ }) .then(response => resolve({ complete: response.data.data.complete, diff --git a/resources/scripts/api/auth/performPasswordReset.ts b/resources/scripts/api/auth/performPasswordReset.ts index f6263c4fe..6695099ee 100644 --- a/resources/scripts/api/auth/performPasswordReset.ts +++ b/resources/scripts/api/auth/performPasswordReset.ts @@ -17,7 +17,6 @@ export default (email: string, data: Data): Promise => { email, token: data.token, password: data.password, - // eslint-disable-next-line @typescript-eslint/camelcase password_confirmation: data.passwordConfirmation, }) .then(response => resolve({ diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index 499932376..8dd8ed22a 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -3,16 +3,15 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http'; export default (query?: string, includeAdmin?: boolean): Promise> => { return new Promise((resolve, reject) => { - http.get(`/api/client`, { + http.get('/api/client', { params: { include: [ 'allocation' ], - // eslint-disable-next-line @typescript-eslint/camelcase - filter: includeAdmin ? 'all' : undefined, - query, + type: includeAdmin ? 'all' : undefined, + 'filter[name]': query, }, }) .then(({ data }) => resolve({ - items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)), + items: (data.data || []).map((datum: any) => rawDataToServerObject(datum)), pagination: getPaginationSet(data.meta.pagination), })) .catch(reject); diff --git a/resources/scripts/api/getSystemPermissions.ts b/resources/scripts/api/getSystemPermissions.ts index 69fb56797..0e7f27caa 100644 --- a/resources/scripts/api/getSystemPermissions.ts +++ b/resources/scripts/api/getSystemPermissions.ts @@ -3,7 +3,7 @@ import http from '@/api/http'; export default (): Promise => { return new Promise((resolve, reject) => { - http.get(`/api/client/permissions`) + http.get('/api/client/permissions') .then(({ data }) => resolve(data.attributes.permissions)) .catch(reject); }); diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 98f74d56c..9ac1b64f8 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -5,7 +5,7 @@ const http: AxiosInstance = axios.create({ timeout: 20000, headers: { 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'application/json', + Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '', }, @@ -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/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 7c73c58a9..720486dd3 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -14,23 +14,21 @@ export interface FileObject { modifiedAt: Date; } -export default (uuid: string, directory?: string): Promise => { - return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/files/list`, { - params: { directory }, - }) - .then(response => resolve((response.data.data || []).map((item: any): FileObject => ({ - uuid: v4(), - name: item.attributes.name, - mode: item.attributes.mode, - size: Number(item.attributes.size), - isFile: item.attributes.is_file, - isSymlink: item.attributes.is_symlink, - isEditable: item.attributes.is_editable, - mimetype: item.attributes.mimetype, - createdAt: new Date(item.attributes.created_at), - modifiedAt: new Date(item.attributes.modified_at), - })))) - .catch(reject); +export default async (uuid: string, directory?: string): Promise => { + const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, { + params: { directory }, }); + + return (data.data || []).map((item: any): FileObject => ({ + uuid: v4(), + name: item.attributes.name, + mode: item.attributes.mode, + size: Number(item.attributes.size), + isFile: item.attributes.is_file, + isSymlink: item.attributes.is_symlink, + isEditable: item.attributes.is_editable, + mimetype: item.attributes.mimetype, + createdAt: new Date(item.attributes.created_at), + modifiedAt: new Date(item.attributes.modified_at), + })); }; diff --git a/resources/scripts/api/server/files/renameFile.ts b/resources/scripts/api/server/files/renameFile.ts index ba483d99c..6b307c837 100644 --- a/resources/scripts/api/server/files/renameFile.ts +++ b/resources/scripts/api/server/files/renameFile.ts @@ -8,9 +8,7 @@ interface Data { export default (uuid: string, { renameFrom, renameTo }: Data): Promise => { return new Promise((resolve, reject) => { http.put(`/api/client/servers/${uuid}/files/rename`, { - // eslint-disable-next-line @typescript-eslint/camelcase rename_from: renameFrom, - // eslint-disable-next-line @typescript-eslint/camelcase rename_to: renameTo, }) .then(() => resolve()) diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 0a0b4e4a5..7072033f1 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -1,10 +1,13 @@ -import http from '@/api/http'; +import http, { FractalResponseData, FractalResponseList } from '@/api/http'; +import { rawDataToServerAllocation } from '@/api/transformers'; export interface Allocation { + id: number; ip: string; alias: string | null; port: number; - default: boolean; + notes: string | null; + isDefault: boolean; } export interface Server { @@ -35,7 +38,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,24 +48,20 @@ export const rawDataToServerObject = (data: any): Server => ({ port: data.sftp_details.port, }, description: data.description ? ((data.description.length > 0) ? data.description : null) : null, - allocations: [ { - ip: data.allocation.ip, - alias: null, - port: data.allocation.port, - default: true, - } ], 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), - data.meta?.is_server_owner ? ['*'] : (data.meta?.user_permissions || []), + rawDataToServerObject(data), + // eslint-disable-next-line camelcase + data.meta?.is_server_owner ? [ '*' ] : (data.meta?.user_permissions || []), ])) .catch(reject); }); diff --git a/resources/scripts/api/server/getServerDatabases.ts b/resources/scripts/api/server/getServerDatabases.ts index 835964c27..cf7c9037d 100644 --- a/resources/scripts/api/server/getServerDatabases.ts +++ b/resources/scripts/api/server/getServerDatabases.ts @@ -18,7 +18,7 @@ export const rawDataToServerDatabase = (data: any): ServerDatabase => ({ password: data.relationships && data.relationships.password ? data.relationships.password.attributes.password : undefined, }); -export default (uuid: string, includePassword: boolean = true): Promise => { +export default (uuid: string, includePassword = true): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${uuid}/databases`, { params: includePassword ? { include: 'password' } : undefined, diff --git a/resources/scripts/api/server/network/deleteServerAllocation.ts b/resources/scripts/api/server/network/deleteServerAllocation.ts new file mode 100644 index 000000000..92fd4b30a --- /dev/null +++ b/resources/scripts/api/server/network/deleteServerAllocation.ts @@ -0,0 +1,4 @@ +import { Allocation } from '@/api/server/getServer'; +import http from '@/api/http'; + +export default async (uuid: string, id: number): Promise => await http.delete(`/api/client/servers/${uuid}/network/allocations/${id}`); diff --git a/resources/scripts/api/server/network/getServerAllocations.ts b/resources/scripts/api/server/network/getServerAllocations.ts new file mode 100644 index 000000000..7309bd266 --- /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/allocations`); + + 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..27c09b722 --- /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, id: number): Promise => { + const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}/primary`); + + return rawDataToServerAllocation(data); +}; diff --git a/resources/scripts/api/server/network/setServerAllocationNotes.ts b/resources/scripts/api/server/network/setServerAllocationNotes.ts new file mode 100644 index 000000000..4531dc751 --- /dev/null +++ b/resources/scripts/api/server/network/setServerAllocationNotes.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, id: number, notes: string | null): Promise => { + const { data } = await http.post(`/api/client/servers/${uuid}/network/allocations/${id}`, { notes }); + + return rawDataToServerAllocation(data); +}; diff --git a/resources/scripts/api/server/reinstallServer.ts b/resources/scripts/api/server/reinstallServer.ts index e931d7991..5cb2ca5e7 100644 --- a/resources/scripts/api/server/reinstallServer.ts +++ b/resources/scripts/api/server/reinstallServer.ts @@ -6,4 +6,4 @@ export default (uuid: string): Promise => { .then(() => resolve()) .catch(reject); }); -} +}; diff --git a/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts b/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts index d48214d09..c0d7fbbe7 100644 --- a/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts +++ b/resources/scripts/api/server/schedules/createOrUpdateScheduleTask.ts @@ -11,7 +11,6 @@ export default (uuid: string, schedule: number, task: number | undefined, { time return new Promise((resolve, reject) => { http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, { ...data, - // eslint-disable-next-line @typescript-eslint/camelcase time_offset: timeOffset, }) .then(({ data }) => resolve(rawDataToServerTask(data.attributes))) diff --git a/resources/scripts/api/server/schedules/deleteScheduleTask.ts b/resources/scripts/api/server/schedules/deleteScheduleTask.ts index 4b5a33296..8867677b2 100644 --- a/resources/scripts/api/server/schedules/deleteScheduleTask.ts +++ b/resources/scripts/api/server/schedules/deleteScheduleTask.ts @@ -5,5 +5,5 @@ export default (uuid: string, scheduleId: number, taskId: number): Promise http.delete(`/api/client/servers/${uuid}/schedules/${scheduleId}/tasks/${taskId}`) .then(() => resolve()) .catch(reject); - }) + }); }; diff --git a/resources/scripts/api/server/schedules/getServerSchedule.ts b/resources/scripts/api/server/schedules/getServerSchedule.ts index 537124bd6..63e3d6f98 100644 --- a/resources/scripts/api/server/schedules/getServerSchedule.ts +++ b/resources/scripts/api/server/schedules/getServerSchedule.ts @@ -5,7 +5,7 @@ export default (uuid: string, schedule: number): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${uuid}/schedules/${schedule}`, { params: { - include: ['tasks'], + include: [ 'tasks' ], }, }) .then(({ data }) => resolve(rawDataToServerSchedule(data.attributes))) diff --git a/resources/scripts/api/server/schedules/getServerSchedules.tsx b/resources/scripts/api/server/schedules/getServerSchedules.tsx index 7d3ae4d1c..42514582a 100644 --- a/resources/scripts/api/server/schedules/getServerSchedules.tsx +++ b/resources/scripts/api/server/schedules/getServerSchedules.tsx @@ -64,7 +64,7 @@ export default (uuid: string): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${uuid}/schedules`, { params: { - include: ['tasks'], + include: [ 'tasks' ], }, }) .then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes)))) diff --git a/resources/scripts/api/server/users/createOrUpdateSubuser.ts b/resources/scripts/api/server/users/createOrUpdateSubuser.ts index fbcf78fe5..93303a2db 100644 --- a/resources/scripts/api/server/users/createOrUpdateSubuser.ts +++ b/resources/scripts/api/server/users/createOrUpdateSubuser.ts @@ -15,4 +15,4 @@ export default (uuid: string, params: Params, subuser?: Subuser): Promise resolve(rawDataToServerSubuser(data.data))) .catch(reject); }); -} +}; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts new file mode 100644 index 000000000..eb54b62fe --- /dev/null +++ b/resources/scripts/api/transformers.ts @@ -0,0 +1,11 @@ +import { Allocation } from '@/api/server/getServer'; +import { FractalResponseData } from '@/api/http'; + +export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ + id: data.attributes.id, + ip: data.attributes.ip, + alias: data.attributes.ip_alias, + port: data.attributes.port, + notes: data.attributes.notes, + isDefault: data.attributes.is_default, +}); diff --git a/resources/scripts/assets/css/GlobalStylesheet.ts b/resources/scripts/assets/css/GlobalStylesheet.ts new file mode 100644 index 000000000..5cc44cea6 --- /dev/null +++ b/resources/scripts/assets/css/GlobalStylesheet.ts @@ -0,0 +1,35 @@ +import tw from 'twin.macro'; +import { createGlobalStyle } from 'styled-components/macro'; + +export default createGlobalStyle` + body { + ${tw`font-sans bg-neutral-800 text-neutral-200`}; + letter-spacing: 0.015em; + } + + h1, h2, h3, h4, h5, h6 { + ${tw`font-medium tracking-normal font-header`}; + } + + p { + ${tw`text-neutral-200 leading-snug font-sans`}; + } + + form { + ${tw`m-0`}; + } + + textarea, select, input, button, button:focus, button:focus-visible { + ${tw`outline-none`}; + } + + input[type=number]::-webkit-outer-spin-button, + input[type=number]::-webkit-inner-spin-button { + -webkit-appearance: none !important; + margin: 0; + } + + input[type=number] { + -moz-appearance: textfield !important; + } +`; diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 99507fc13..dac7fd102 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -8,9 +8,10 @@ import ServerRouter from '@/routers/ServerRouter'; import AuthenticationRouter from '@/routers/AuthenticationRouter'; import { Provider } from 'react-redux'; import { SiteSettings } from '@/state/settings'; -import { DefaultTheme, ThemeProvider } from 'styled-components'; import ProgressBar from '@/components/elements/ProgressBar'; import NotFound from '@/components/screens/NotFound'; +import tw from 'twin.macro'; +import GlobalStylesheet from '@/assets/css/GlobalStylesheet'; interface ExtendedWindow extends Window { SiteConfiguration?: SiteSettings; @@ -18,24 +19,16 @@ interface ExtendedWindow extends Window { uuid: string; username: string; email: string; + /* eslint-disable camelcase */ root_admin: boolean; use_totp: boolean; language: string; updated_at: string; created_at: string; + /* eslint-enable camelcase */ }; } -const theme: DefaultTheme = { - breakpoints: { - xs: 0, - sm: 576, - md: 768, - lg: 992, - xl: 1200, - }, -}; - const App = () => { const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow); if (PterodactylUser && !store.getState().user.data) { @@ -56,11 +49,12 @@ const App = () => { } return ( - + <> + -
+
@@ -72,7 +66,7 @@ const App = () => {
- + ); }; diff --git a/resources/scripts/components/FlashMessageRender.tsx b/resources/scripts/components/FlashMessageRender.tsx index cb0b37026..6bd22f891 100644 --- a/resources/scripts/components/FlashMessageRender.tsx +++ b/resources/scripts/components/FlashMessageRender.tsx @@ -1,38 +1,35 @@ import React from 'react'; import MessageBox from '@/components/MessageBox'; -import { State, useStoreState } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import { useStoreState } from 'easy-peasy'; +import tw from 'twin.macro'; type Props = Readonly<{ byKey?: string; - spacerClass?: string; className?: string; }>; -export default ({ className, spacerClass, byKey }: Props) => { - const flashes = useStoreState((state: State) => state.flashes.items); - - let filtered = flashes; - if (byKey) { - filtered = flashes.filter(flash => flash.key === byKey); - } - - if (filtered.length === 0) { - return null; - } +const FlashMessageRender = ({ byKey, className }: Props) => { + const flashes = useStoreState(state => state.flashes.items.filter( + flash => byKey ? flash.key === byKey : true, + )); return ( -
- { - filtered.map((flash, index) => ( - - {index > 0 &&
} - - {flash.message} - -
- )) - } -
+ flashes.length ? +
+ { + flashes.map((flash, index) => ( + + {index > 0 &&
} + + {flash.message} + +
+ )) + } +
+ : + null ); }; + +export default FlashMessageRender; diff --git a/resources/scripts/components/MessageBox.tsx b/resources/scripts/components/MessageBox.tsx index a962afb88..e16986ed5 100644 --- a/resources/scripts/components/MessageBox.tsx +++ b/resources/scripts/components/MessageBox.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import tw, { TwStyle } from 'twin.macro'; +import styled from 'styled-components/macro'; export type FlashMessageType = 'success' | 'info' | 'warning' | 'error'; @@ -8,11 +10,60 @@ interface Props { type?: FlashMessageType; } -export default ({ title, children, type }: Props) => ( -
- {title && {title}} - +const styling = (type?: FlashMessageType): TwStyle | string => { + switch (type) { + case 'error': + return tw`bg-red-600 border-red-800`; + case 'info': + return tw`bg-primary-600 border-primary-800`; + case 'success': + return tw`bg-green-600 border-green-800`; + case 'warning': + return tw`bg-yellow-600 border-yellow-800`; + default: + return ''; + } +}; + +const getBackground = (type?: FlashMessageType): TwStyle | string => { + switch (type) { + case 'error': + return tw`bg-red-500`; + case 'info': + return tw`bg-primary-500`; + case 'success': + return tw`bg-green-500`; + case 'warning': + return tw`bg-yellow-500`; + default: + return ''; + } +}; + +const Container = styled.div<{ $type?: FlashMessageType }>` + ${tw`p-2 border items-center leading-normal rounded flex w-full text-sm text-white`}; + ${props => styling(props.$type)}; +`; +Container.displayName = 'MessageBox.Container'; + +const MessageBox = ({ title, children, type }: Props) => ( + + {title && + + {title} + + } + {children} -
+ ); +MessageBox.displayName = 'MessageBox'; + +export default MessageBox; diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 7ff60bef0..206e035c3 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -1,51 +1,77 @@ import * as React from 'react'; import { Link, NavLink } from 'react-router-dom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faLayerGroup } from '@fortawesome/free-solid-svg-icons/faLayerGroup'; -import { faUserCircle } from '@fortawesome/free-solid-svg-icons/faUserCircle'; -import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons/faSignOutAlt'; -import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook'; -import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs'; +import { faCogs, faLayerGroup, faSignOutAlt, faUserCircle } from '@fortawesome/free-solid-svg-icons'; import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; -import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; import SearchContainer from '@/components/dashboard/search/SearchContainer'; +import tw from 'twin.macro'; +import styled from 'styled-components/macro'; +// @ts-ignore +import * as config from '@/../../tailwind.config.js'; + +const Navigation = styled.div` + ${tw`w-full bg-neutral-900 shadow-md`}; + + & > div { + ${tw`mx-auto w-full flex items-center`}; + } + + & #logo { + ${tw`flex-1`}; + + & > a { + ${tw`text-2xl font-header px-4 no-underline text-neutral-200 hover:text-neutral-100 transition-colors duration-150`}; + } + } +`; + +const RightNavigation = styled.div` + ${tw`flex h-full items-center justify-center`}; + + & > a, & > .navigation-link { + ${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`}; + + &:active, &:hover { + ${tw`text-neutral-100 bg-black`}; + } + + &:active, &:hover, &.active { + box-shadow: inset 0 -2px ${config.theme.colors.cyan['700']}; + } + } +`; export default () => { const user = useStoreState((state: ApplicationStore) => state.user.data!); const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); return ( -
-
+ +
{name}
-
+ - + {user.rootAdmin && - + } - {process.env.NODE_ENV !== 'production' && - - - - } -
+
-
+ ); }; diff --git a/resources/scripts/components/NetworkErrorMessage.tsx b/resources/scripts/components/NetworkErrorMessage.tsx deleted file mode 100644 index 8d4150f2c..000000000 --- a/resources/scripts/components/NetworkErrorMessage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import MessageBox from '@/components/MessageBox'; - -export default ({ message }: { message: string | undefined | null }) => ( - !message ? - null - : -
- - {message} - -
-); diff --git a/resources/scripts/components/ServerOverviewContainer.tsx b/resources/scripts/components/ServerOverviewContainer.tsx deleted file mode 100644 index 8cc5cf7cb..000000000 --- a/resources/scripts/components/ServerOverviewContainer.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import { NavLink } from 'react-router-dom'; - -export default class ServerOverviewContainer extends React.PureComponent { - render () { - return ( -
- Account - Design -
- ); - } -} diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index 0f923eb38..dbd4ed469 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -8,6 +8,8 @@ import { ApplicationStore } from '@/state'; import Field from '@/components/elements/Field'; import { Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { email: string; @@ -43,33 +45,30 @@ export default () => { {({ isSubmitting }) => ( -
- + Send Email +
-
+
Return to Login diff --git a/resources/scripts/components/auth/LoginCheckpointContainer.tsx b/resources/scripts/components/auth/LoginCheckpointContainer.tsx index dddcd0c1c..d2c3c8643 100644 --- a/resources/scripts/components/auth/LoginCheckpointContainer.tsx +++ b/resources/scripts/components/auth/LoginCheckpointContainer.tsx @@ -5,19 +5,19 @@ import { httpErrorToHuman } from '@/api/http'; import LoginFormContainer from '@/components/auth/LoginFormContainer'; import { ActionCreator } from 'easy-peasy'; import { StaticContext } from 'react-router'; -import Spinner from '@/components/elements/Spinner'; import { useFormikContext, withFormik } from 'formik'; -import { object, string } from 'yup'; import useFlash from '@/plugins/useFlash'; import { FlashStore } from '@/state/flashes'; import Field from '@/components/elements/Field'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { code: string; recoveryCode: '', } -type OwnProps = RouteComponentProps<{}, StaticContext, { token?: string }> +type OwnProps = RouteComponentProps, StaticContext, { token?: string }> type Props = OwnProps & { addError: ActionCreator; @@ -29,13 +29,10 @@ const LoginCheckpointContainer = () => { const [ isMissingDevice, setIsMissingDevice ] = useState(false); return ( - -
+ +
{ : 'Enter the two-factor token generated by your device.' } type={isMissingDevice ? 'text' : 'number'} - autoFocus={true} + autoFocus />
-
- + Continue +
-
+
{ setFieldValue('code', ''); setFieldValue('recoveryCode', ''); setIsMissingDevice(s => !s); }} - className={'cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'} + css={tw`cursor-pointer text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`} > {!isMissingDevice ? 'I\'ve Lost My Device' : 'I Have My Device'}
-
+
Return to Login diff --git a/resources/scripts/components/auth/LoginContainer.tsx b/resources/scripts/components/auth/LoginContainer.tsx index bebc6a059..b520dd7e7 100644 --- a/resources/scripts/components/auth/LoginContainer.tsx +++ b/resources/scripts/components/auth/LoginContainer.tsx @@ -10,7 +10,8 @@ import Field from '@/components/elements/Field'; import { httpErrorToHuman } from '@/api/http'; import { FlashMessage } from '@/state/flashes'; import ReCAPTCHA from 'react-google-recaptcha'; -import Spinner from '@/components/elements/Spinner'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; type OwnProps = RouteComponentProps & { clearFlashes: ActionCreator; @@ -34,38 +35,27 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl return ( {ref.current && ref.current.render()} - - + -
- +
-
- +
+
{recaptchaEnabled && setFieldValue('recaptchaData', null)} /> } -
+
Forgot password? @@ -96,7 +86,7 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl const EnhancedForm = withFormik({ displayName: 'LoginContainerForm', - mapPropsToValues: (props) => ({ + mapPropsToValues: () => ({ username: '', password: '', recaptchaData: null, diff --git a/resources/scripts/components/auth/LoginFormContainer.tsx b/resources/scripts/components/auth/LoginFormContainer.tsx index afc8093fc..2423e0251 100644 --- a/resources/scripts/components/auth/LoginFormContainer.tsx +++ b/resources/scripts/components/auth/LoginFormContainer.tsx @@ -1,8 +1,9 @@ import React, { forwardRef } from 'react'; import { Form } from 'formik'; -import styled from 'styled-components'; -import { breakpoint } from 'styled-components-breakpoint'; +import styled from 'styled-components/macro'; +import { breakpoint } from '@/theme'; import FlashMessageRender from '@/components/FlashMessageRender'; +import tw from 'twin.macro'; type Props = React.DetailedHTMLProps, HTMLFormElement> & { title?: string; @@ -29,27 +30,29 @@ const Container = styled.div` export default forwardRef(({ title, ...props }, ref) => ( - {title &&

+ {title && +

{title} -

} - + + } +
-
-
- +
+
+
-
+
{props.children}
-

+

© 2015 - 2020  Pterodactyl Software diff --git a/resources/scripts/components/auth/ResetPasswordContainer.tsx b/resources/scripts/components/auth/ResetPasswordContainer.tsx index 7350e725a..cded2e9ac 100644 --- a/resources/scripts/components/auth/ResetPasswordContainer.tsx +++ b/resources/scripts/components/auth/ResetPasswordContainer.tsx @@ -7,19 +7,19 @@ import { httpErrorToHuman } from '@/api/http'; import LoginFormContainer from '@/components/auth/LoginFormContainer'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; -import Spinner from '@/components/elements/Spinner'; import { Formik, FormikHelpers } from 'formik'; import { object, ref, string } from 'yup'; import Field from '@/components/elements/Field'; - -type Props = Readonly & {}>; +import Input from '@/components/elements/Input'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { password: string; passwordConfirmation: string; } -export default ({ match, history, location }: Props) => { +export default ({ match, location }: RouteComponentProps<{ token: string }>) => { const [ email, setEmail ] = useState(''); const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); @@ -56,52 +56,50 @@ export default ({ match, history, location }: Props) => { .min(8, 'Your new password should be at least 8 characters in length.'), passwordConfirmation: string() .required('Your new password does not match.') - .oneOf([ref('password'), null], 'Your new password does not match.'), + // @ts-ignore + .oneOf([ ref('password'), null ], 'Your new password does not match.'), })} > {({ isSubmitting }) => (

- +
-
+
-
+
-
- + Reset Password +
-
+
Return to Login diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index 3d952ac2f..f3ceb66f0 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -4,16 +4,17 @@ import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; import getApiKeys, { ApiKey } from '@/api/account/getApiKeys'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faKey } from '@fortawesome/free-solid-svg-icons/faKey'; -import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; +import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; import deleteApiKey from '@/api/account/deleteApiKey'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import FlashMessageRender from '@/components/FlashMessageRender'; import { httpErrorToHuman } from '@/api/http'; -import format from 'date-fns/format'; +import { format } from 'date-fns'; import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; +import GreyRowBox from '@/components/elements/GreyRowBox'; export default () => { const [ deleteIdentifier, setDeleteIdentifier ] = useState(''); @@ -48,18 +49,18 @@ export default () => { return ( - -
- + +
+ setKeys(s => ([ ...s!, key ]))}/> - + {deleteIdentifier && { doDeletion(deleteIdentifier); setDeleteIdentifier(''); @@ -72,38 +73,38 @@ export default () => { } { keys.length === 0 ? -

+

{loading ? 'Loading...' : 'No API keys exist for this account.'}

: - keys.map(key => ( -
( + 0 && tw`mt-2` ]} > - -
-

{key.description}

-

- Last - used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'} + +

+

{key.description}

+

+ Last used:  + {key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}

-

- +

+ {key.identifier}

-
+
)) } diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index 4b052e850..e98ddd4a6 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -3,9 +3,10 @@ import ContentBox from '@/components/elements/ContentBox'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; import ConfigureTwoFactorForm from '@/components/dashboard/forms/ConfigureTwoFactorForm'; -import styled from 'styled-components'; -import { breakpoint } from 'styled-components-breakpoint'; import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; +import { breakpoint } from '@/theme'; +import styled from 'styled-components/macro'; const Container = styled.div` ${tw`flex flex-wrap my-10`}; @@ -31,13 +32,13 @@ export default () => { - + diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index ad8f30d6f..6daf78f0d 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender'; import { useStoreState } from 'easy-peasy'; import { usePersistedState } from '@/plugins/usePersistedState'; import Switch from '@/components/elements/Switch'; +import tw from 'twin.macro'; export default () => { const { addError, clearFlashes } = useFlash(); @@ -37,10 +38,10 @@ export default () => { return ( - + {rootAdmin && -
-

+

+

{showAdmin ? 'Showing all servers' : 'Showing your servers'}

{
} {loading ? - + : servers.length > 0 ? - servers.map(server => ( - + servers.map((server, index) => ( +
0 ? tw`mt-2` : undefined}> + +
)) : -

+

There are no servers associated with your account.

} diff --git a/resources/scripts/components/dashboard/DesignElementsContainer.tsx b/resources/scripts/components/dashboard/DesignElementsContainer.tsx deleted file mode 100644 index 1e9747868..000000000 --- a/resources/scripts/components/dashboard/DesignElementsContainer.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import * as React from 'react'; -import { Link } from 'react-router-dom'; -import ContentBox from '@/components/elements/ContentBox'; - -export default class DesignElementsContainer extends React.PureComponent { - render () { - return ( - -
-
- -

- Your demands have been received: Dark Mode will be default in Pterodactyl 0.8! -

-

Back

-
-
-

Form Elements

-
- - -

- This is some descriptive helper text to explain how things work. -

-
- - -

- This field has an error. -

-
- - -
- - -
- - -
- - - - -
- - -
- - -
-
-
-
- - ); - } -} diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index a212bce0c..a438750e7 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -1,16 +1,13 @@ import React, { useEffect, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faServer } from '@fortawesome/free-solid-svg-icons/faServer'; -import { faEthernet } from '@fortawesome/free-solid-svg-icons/faEthernet'; -import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip'; -import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory'; -import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd'; +import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import { bytesToHuman } from '@/helpers'; -import classNames from 'classnames'; +import tw from 'twin.macro'; +import GreyRowBox from '@/components/elements/GreyRowBox'; // Determines if the current value is in an alarm threshold so we can show it in red rather // than the more faded default style. @@ -20,7 +17,7 @@ const isAlarmState = (current: number, limit: number): boolean => { return current / limitInBytes >= 0.90; }; -export default ({ server, className }: { server: Server; className: string | undefined }) => { +export default ({ server }: { server: Server }) => { const interval = useRef(null); const [ stats, setStats ] = useState(null); const [ statsError, setStatsError ] = useState(false); @@ -52,108 +49,111 @@ export default ({ server, className }: { server: Server; className: string | und alarms.memory = isAlarmState(stats.memoryUsageInBytes, server.limits.memory); alarms.disk = server.limits.disk === 0 ? false : isAlarmState(stats.diskUsageInBytes, server.limits.disk); } - const disklimit = server.limits.disk != 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : "Unlimited"; - const memorylimit = server.limits.memory != 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : "Unlimited"; + const disklimit = server.limits.disk !== 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : 'Unlimited'; + const memorylimit = server.limits.memory !== 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited'; return ( - +
-
-

{server.name}

+
+

{server.name}

-
-
- -

+

+
+ +

{ - server.allocations.filter(alloc => alloc.default).map(allocation => ( + server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( {allocation.alias || allocation.ip}:{allocation.port} )) }

-
+
{!stats ? !statsError ? - + : server.isInstalling ? -
- +
+ Installing
: -
- +
+ {server.isSuspended ? 'Suspended' : 'Connection Error'}
: -
+

{stats.cpuUsagePercent} %

-
-
+
+

{bytesToHuman(stats.memoryUsageInBytes)}

-

of {memorylimit}

+

of {memorylimit}

-
-
+
+

{bytesToHuman(stats.diskUsageInBytes)}

-

of {disklimit}

+

of {disklimit}

}
- + ); }; diff --git a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx index dc6b467a6..0b0db4d77 100644 --- a/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx +++ b/resources/scripts/components/dashboard/forms/ConfigureTwoFactorForm.tsx @@ -3,6 +3,8 @@ import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import SetupTwoFactorModal from '@/components/dashboard/forms/SetupTwoFactorModal'; import DisableTwoFactorModal from '@/components/dashboard/forms/DisableTwoFactorModal'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; export default () => { const user = useStoreState((state: ApplicationStore) => state.user.data!); @@ -12,43 +14,45 @@ export default () => {
{visible && setVisible(false)} /> } -

+

Two-factor authentication is currently enabled on your account.

-
- +
:
{visible && setVisible(false)} /> } -

+

You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.

-
- +
; diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index cf1a596bf..4e8fae1d5 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -9,6 +9,9 @@ import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import { ApiKey } from '@/api/account/getApiKeys'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import Input, { Textarea } from '@/components/elements/Input'; interface Values { description: string; @@ -44,22 +47,21 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { closeOnEscape={false} closeOnBackground={false} > -

Your API Key

-

+

Your API Key

+

The API key you have requested is shown below. Please store this in a safe location, it will not be shown again.

-
-                    {apiKey}
+                
+                    {apiKey}
                 
-
- +
void }) => { label={'Description'} name={'description'} description={'A description of this API key.'} - className={'mb-6'} + css={tw`mb-6`} > - + - + -
- +
+
)} diff --git a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx index 6cadc595b..e0b52415a 100644 --- a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx @@ -8,6 +8,8 @@ import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor'; import { httpErrorToHuman } from '@/api/http'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { password: string; @@ -45,19 +47,19 @@ export default ({ ...props }: RequiredModalProps) => { {({ isSubmitting, isValid }) => (
- + -
- +
diff --git a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx index ff5eeec7f..d647bc407 100644 --- a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx @@ -9,6 +9,8 @@ import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; import Field from '@/components/elements/Field'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { code: string; @@ -64,7 +66,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => { .matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'), })} > - {({ isSubmitting, isValid }) => ( + {({ isSubmitting }) => ( { > {recoveryTokens.length > 0 ? <> -

Two-factor authentication enabled

-

+

Two-factor authentication enabled

+

Two-factor authentication has been enabled on your account. Should you loose access to - this device you'll need to use on of the codes displayed below in order to access your + this device you'll need to use on of the codes displayed below in order to access your account.

-

+

These codes will not be displayed again. Please take note of them now by storing them in a secure repository such as a password manager.

-
-                                {recoveryTokens.map(token => {token})}
+                            
+                                {recoveryTokens.map(token => {token})}
                             
-
- +
: -
- -
-
-
+ + +
+
+
{!token || !token.length ? : setLoading(false)} - className={'w-full h-full shadow-none rounded-0'} + css={tw`w-full h-full shadow-none rounded-none`} /> }
-
-
+
+
{ autoFocus={!loading} />
-
- +
diff --git a/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx b/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx index 2563e1386..547cb26ce 100644 --- a/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx +++ b/resources/scripts/components/dashboard/forms/UpdateEmailAddressForm.tsx @@ -6,6 +6,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import Field from '@/components/elements/Field'; import { httpErrorToHuman } from '@/api/http'; import { ApplicationStore } from '@/state'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { email: string; @@ -54,14 +56,14 @@ export default () => { ({ isSubmitting, isValid }) => ( - + -
+
{ label={'Confirm Password'} />
-
- +
diff --git a/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx b/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx index 17809b496..7dec924a5 100644 --- a/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx +++ b/resources/scripts/components/dashboard/forms/UpdatePasswordForm.tsx @@ -7,6 +7,8 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import updateAccountPassword from '@/api/account/updateAccountPassword'; import { httpErrorToHuman } from '@/api/http'; import { ApplicationStore } from '@/state'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface Values { current: string; @@ -30,7 +32,7 @@ export default () => { return null; } - const submit = (values: Values, { resetForm, setSubmitting }: FormikHelpers) => { + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('account:password'); updateAccountPassword({ ...values }) .then(() => { @@ -57,14 +59,14 @@ export default () => { ({ isSubmitting, isValid }) => ( -
+ -
+
{ description={'Your new password should be at least 8 characters in length and unique to this website.'} />
-
+
{ label={'Confirm New Password'} />
-
- +
diff --git a/resources/scripts/components/dashboard/search/SearchContainer.tsx b/resources/scripts/components/dashboard/search/SearchContainer.tsx index 475d65510..d552d2c1d 100644 --- a/resources/scripts/components/dashboard/search/SearchContainer.tsx +++ b/resources/scripts/components/dashboard/search/SearchContainer.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import { faSearch } from '@fortawesome/free-solid-svg-icons'; import useEventListener from '@/plugins/useEventListener'; import SearchModal from '@/components/dashboard/search/SearchModal'; @@ -19,7 +19,7 @@ export default () => { <> {visible && setVisible(false)} /> diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx index 9c2a4eb9c..1461b8013 100644 --- a/resources/scripts/components/dashboard/search/SearchModal.tsx +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -3,7 +3,7 @@ import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik'; import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { object, string } from 'yup'; -import { debounce } from 'lodash-es'; +import debounce from 'debounce'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import InputSpinner from '@/components/elements/InputSpinner'; import getServers from '@/api/getServers'; @@ -11,7 +11,9 @@ import { Server } from '@/api/server/getServer'; import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import { Link } from 'react-router-dom'; -import styled from 'styled-components'; +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; +import Input from '@/components/elements/Input'; type Props = RequiredModalProps; @@ -20,8 +22,7 @@ interface Values { } const ServerResult = styled(Link)` - ${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline`}; - transition: all 250ms linear; + ${tw`flex items-center bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline transition-all duration-150`}; &:hover { ${tw`shadow border-cyan-500`}; @@ -55,6 +56,7 @@ export default ({ ...props }: Props) => { setLoading(true); setSubmitting(false); clearFlashes('search'); + getServers(term) .then(servers => setServers(servers.items.filter((_, index) => index < 5))) .catch(error => { @@ -93,16 +95,12 @@ export default ({ ...props }: Props) => { > - + {servers.length > 0 && -
+
{ servers.map(server => ( { onClick={() => props.onDismissed()} >
-

{server.name}

-

+

{server.name}

+

{ - server.allocations.filter(alloc => alloc.default).map(allocation => ( + server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( {allocation.alias || allocation.ip}:{allocation.port} )) }

-
- +
+ {server.node}
diff --git a/resources/scripts/components/elements/AceEditor.tsx b/resources/scripts/components/elements/AceEditor.tsx index 3b01307fa..0b4ebca95 100644 --- a/resources/scripts/components/elements/AceEditor.tsx +++ b/resources/scripts/components/elements/AceEditor.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useEffect, useState, lazy } from 'react'; -import useRouter from 'use-react-router'; -import { ServerContext } from '@/state/server'; +import React, { useCallback, useEffect, useState } from 'react'; import ace, { Editor } from 'brace'; -import getFileContents from '@/api/server/files/getFileContents'; -import styled from 'styled-components'; +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; +import Select from '@/components/elements/Select'; +// @ts-ignore +import modes from '@/modes'; // @ts-ignore require('brace/ext/modelist'); @@ -11,7 +12,7 @@ require('ayu-ace/mirage'); const EditorContainer = styled.div` min-height: 16rem; - height: calc(100vh - 16rem); + height: calc(100vh - 20rem); ${tw`relative`}; #editor { @@ -19,35 +20,6 @@ const EditorContainer = styled.div` } `; -const modes: { [k: string]: string } = { - // eslint-disable-next-line @typescript-eslint/camelcase - assembly_x86: 'Assembly (x86)', - // eslint-disable-next-line @typescript-eslint/camelcase - c_cpp: 'C++', - coffee: 'Coffeescript', - css: 'CSS', - dockerfile: 'Dockerfile', - golang: 'Go', - html: 'HTML', - ini: 'Ini', - java: 'Java', - javascript: 'Javascript', - json: 'JSON', - kotlin: 'Kotlin', - lua: 'Luascript', - perl: 'Perl', - php: 'PHP', - properties: 'Properties', - python: 'Python', - ruby: 'Ruby', - // eslint-disable-next-line @typescript-eslint/camelcase - plain_text: 'Plaintext', - toml: 'TOML', - typescript: 'Typescript', - xml: 'XML', - yaml: 'YAML', -}; - Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`)); export interface Props { @@ -70,7 +42,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten useEffect(() => { editor && editor.session.setMode(mode); - }, [editor, mode]); + }, [ editor, mode ]); useEffect(() => { editor && editor.session.setValue(initialContent || ''); @@ -113,19 +85,18 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten return (
-
-
- setMode(`ace/mode/${e.currentTarget.value}`)} > { Object.keys(modes).map(key => ( - + )) } - +
diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index 04bb13a81..300f1a9ea 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -1,20 +1,99 @@ import React from 'react'; -import classNames from 'classnames'; +import styled, { css } from 'styled-components/macro'; +import tw from 'twin.macro'; +import Spinner from '@/components/elements/Spinner'; -type Props = { isLoading?: boolean } & React.DetailedHTMLProps, HTMLButtonElement>; +interface Props { + isLoading?: boolean; + size?: 'xsmall' | 'small' | 'large' | 'xlarge'; + color?: 'green' | 'red' | 'primary' | 'grey'; + isSecondary?: boolean; +} -export default ({ isLoading, children, className, ...props }: Props) => ( - + ); + +type LinkProps = Omit & Props; + +const LinkButton: React.FC = props => ; + +export { LinkButton, ButtonStyle }; +export default Button; diff --git a/resources/scripts/components/elements/Checkbox.tsx b/resources/scripts/components/elements/Checkbox.tsx index bd7b7a708..ae7548dcf 100644 --- a/resources/scripts/components/elements/Checkbox.tsx +++ b/resources/scripts/components/elements/Checkbox.tsx @@ -1,14 +1,15 @@ import React from 'react'; import { Field, FieldProps } from 'formik'; +import Input from '@/components/elements/Input'; interface Props { name: string; value: string; } -type OmitFields = 'name' | 'value' | 'type' | 'checked' | 'onChange'; +type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange'; -type InputProps = Omit, HTMLInputElement>, OmitFields>; +type InputProps = Omit; const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( @@ -20,7 +21,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( } return ( - onDismissed()} > -

{title}

-

{children}

-
- - + +
); diff --git a/resources/scripts/components/elements/ContentBox.tsx b/resources/scripts/components/elements/ContentBox.tsx index 94c9ad629..4eb777c03 100644 --- a/resources/scripts/components/elements/ContentBox.tsx +++ b/resources/scripts/components/elements/ContentBox.tsx @@ -1,7 +1,7 @@ -import * as React from 'react'; -import classNames from 'classnames'; +import React from 'react'; import FlashMessageRender from '@/components/FlashMessageRender'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import tw from 'twin.macro'; type Props = Readonly, HTMLDivElement> & { title?: string; @@ -12,16 +12,19 @@ type Props = Readonly (
- {title &&

{title}

} + {title &&

{title}

} {showFlashes && } -
+
{children}
diff --git a/resources/scripts/components/elements/ContentContainer.tsx b/resources/scripts/components/elements/ContentContainer.tsx index c26cd9d8a..799f512d2 100644 --- a/resources/scripts/components/elements/ContentContainer.tsx +++ b/resources/scripts/components/elements/ContentContainer.tsx @@ -1,5 +1,6 @@ -import styled from 'styled-components'; -import { breakpoint } from 'styled-components-breakpoint'; +import styled from 'styled-components/macro'; +import { breakpoint } from '@/theme'; +import tw from 'twin.macro'; const ContentContainer = styled.div` max-width: 1200px; @@ -9,5 +10,6 @@ const ContentContainer = styled.div` ${tw`mx-auto`}; `}; `; +ContentContainer.displayName = 'ContentContainer'; export default ContentContainer; diff --git a/resources/scripts/components/elements/DropdownMenu.tsx b/resources/scripts/components/elements/DropdownMenu.tsx index e9a326f77..ccf16ee6e 100644 --- a/resources/scripts/components/elements/DropdownMenu.tsx +++ b/resources/scripts/components/elements/DropdownMenu.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { CSSTransition } from 'react-transition-group'; -import styled from 'styled-components'; +import React, { createRef } from 'react'; +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; +import Fade from '@/components/elements/Fade'; interface Props { children: React.ReactNode; @@ -12,76 +13,95 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>` transition: 150ms all ease; &:hover { - ${props => props.danger - ? tw`text-red-700 bg-red-100` - : tw`text-neutral-700 bg-neutral-100` - }; + ${props => props.danger ? tw`text-red-700 bg-red-100` : tw`text-neutral-700 bg-neutral-100`}; } `; -const DropdownMenu = ({ renderToggle, children }: Props) => { - const menu = useRef(null); - const [ posX, setPosX ] = useState(0); - const [ visible, setVisible ] = useState(false); +interface State { + posX: number; + visible: boolean; +} - const onClickHandler = (e: React.MouseEvent) => { +class DropdownMenu extends React.PureComponent { + menu = createRef(); + + state: State = { + posX: 0, + visible: false, + }; + + componentWillUnmount () { + this.removeListeners(); + } + + componentDidUpdate (prevProps: Readonly, prevState: Readonly) { + const menu = this.menu.current; + + if (this.state.visible && !prevState.visible && menu) { + document.addEventListener('click', this.windowListener); + document.addEventListener('contextmenu', this.contextMenuListener); + menu.setAttribute( + 'style', `left: ${Math.round(this.state.posX - menu.clientWidth)}px`, + ); + } + + if (!this.state.visible && prevState.visible) { + this.removeListeners(); + } + } + + removeListeners = () => { + document.removeEventListener('click', this.windowListener); + document.removeEventListener('contextmenu', this.contextMenuListener); + }; + + onClickHandler = (e: React.MouseEvent) => { e.preventDefault(); - - !visible && setPosX(e.clientX); - setVisible(s => !s); + this.triggerMenu(e.clientX); }; - const windowListener = (e: MouseEvent) => { - if (e.button === 2 || !visible || !menu.current) { + contextMenuListener = () => this.setState({ visible: false }); + + windowListener = (e: MouseEvent) => { + const menu = this.menu.current; + + if (e.button === 2 || !this.state.visible || !menu) { return; } - if (e.target === menu.current || menu.current.contains(e.target as Node)) { + if (e.target === menu || menu.contains(e.target as Node)) { return; } - if (e.target !== menu.current && !menu.current.contains(e.target as Node)) { - setVisible(false); + if (e.target !== menu && !menu.contains(e.target as Node)) { + this.setState({ visible: false }); } }; - useEffect(() => { - if (!visible || !menu.current) { - return; - } + triggerMenu = (posX: number) => this.setState(s => ({ + posX: !s.visible ? posX : s.posX, + visible: !s.visible, + })); - document.addEventListener('click', windowListener); - menu.current.setAttribute( - 'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`, + render () { + return ( +
+ {this.props.renderToggle(this.onClickHandler)} + +
{ + e.stopPropagation(); + this.setState({ visible: false }); + }} + css={tw`absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48`} + > + {this.props.children} +
+
+
); - - return () => { - document.removeEventListener('click', windowListener); - } - }, [ visible ]); - - return ( -
- {renderToggle(onClickHandler)} - -
{ - e.stopPropagation(); - setVisible(false); - }} - className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'} - > - {children} -
-
-
- ); -}; + } +} export default DropdownMenu; diff --git a/resources/scripts/components/elements/Fade.tsx b/resources/scripts/components/elements/Fade.tsx new file mode 100644 index 000000000..62850283e --- /dev/null +++ b/resources/scripts/components/elements/Fade.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import tw from 'twin.macro'; +import styled from 'styled-components/macro'; +import CSSTransition, { CSSTransitionProps } from 'react-transition-group/CSSTransition'; + +interface Props extends Omit { + timeout: number; +} + +const Container = styled.div<{ timeout: number }>` + .fade-enter, .fade-exit { + will-change: opacity; + } + + .fade-enter { + ${tw`opacity-0`}; + + &.fade-enter-active { + ${tw`opacity-100 transition-opacity ease-in`}; + transition-duration: ${props => props.timeout}ms; + } + } + + .fade-exit { + ${tw`opacity-100`}; + + &.fade-exit-active { + ${tw`opacity-0 transition-opacity ease-in`}; + transition-duration: ${props => props.timeout}ms; + } + } +`; + +const Fade: React.FC = ({ timeout, children, ...props }) => ( + + + {children} + + +); +Fade.displayName = 'Fade'; + +export default Fade; diff --git a/resources/scripts/components/elements/Field.tsx b/resources/scripts/components/elements/Field.tsx index feb819b47..0c76f285c 100644 --- a/resources/scripts/components/elements/Field.tsx +++ b/resources/scripts/components/elements/Field.tsx @@ -1,6 +1,7 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; import { Field as FormikField, FieldProps } from 'formik'; -import classNames from 'classnames'; +import Input from '@/components/elements/Input'; +import Label from '@/components/elements/Label'; interface OwnProps { name: string; @@ -12,21 +13,20 @@ interface OwnProps { type Props = OwnProps & Omit, 'name'>; -const Field = ({ id, name, light = false, label, description, validate, className, ...props }: Props) => ( - +const Field = forwardRef(({ id, name, light = false, label, description, validate, ...props }, ref) => ( + { ({ field, form: { errors, touched } }: FieldProps) => ( - + <> {label && - + } - {touched[field.name] && errors[field.name] ?

@@ -35,10 +35,11 @@ const Field = ({ id, name, light = false, label, description, validate, classNam : description ?

{description}

: null } -
+ ) }
-); +)); +Field.displayName = 'Field'; export default Field; diff --git a/resources/scripts/components/elements/FormikFieldWrapper.tsx b/resources/scripts/components/elements/FormikFieldWrapper.tsx index ec6a0fb13..208f47a22 100644 --- a/resources/scripts/components/elements/FormikFieldWrapper.tsx +++ b/resources/scripts/components/elements/FormikFieldWrapper.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Field, FieldProps } from 'formik'; -import classNames from 'classnames'; import InputError from '@/components/elements/InputError'; +import Label from '@/components/elements/Label'; interface Props { id?: string; @@ -17,11 +17,11 @@ const FormikFieldWrapper = ({ id, name, label, className, description, validate, { ({ field, form: { errors, touched } }: FieldProps) => ( -
- {label && } +
+ {label && } {children} - {description ?

{description}

: null} + {description || null}
) diff --git a/resources/scripts/components/elements/GreyRowBox.tsx b/resources/scripts/components/elements/GreyRowBox.tsx new file mode 100644 index 000000000..e86c61dc2 --- /dev/null +++ b/resources/scripts/components/elements/GreyRowBox.tsx @@ -0,0 +1,12 @@ +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; + +export default styled.div<{ $hoverable?: boolean }>` + ${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150`}; + + ${props => props.$hoverable !== false && tw`hover:border-neutral-500`}; + + & > div.icon { + ${tw`rounded-full bg-neutral-500 p-3`}; + } +`; diff --git a/resources/scripts/components/elements/Input.tsx b/resources/scripts/components/elements/Input.tsx new file mode 100644 index 000000000..e29eaf6e9 --- /dev/null +++ b/resources/scripts/components/elements/Input.tsx @@ -0,0 +1,81 @@ +import styled, { css } from 'styled-components/macro'; +import tw from 'twin.macro'; + +export interface Props { + isLight?: boolean; + hasError?: boolean; +} + +const light = css` + ${tw`bg-white border-neutral-200 text-neutral-800`}; + &:focus { ${tw`border-primary-400`} } + + &:disabled { + ${tw`bg-neutral-100 border-neutral-200`}; + } +`; + +const checkboxStyle = css` + ${tw`cursor-pointer appearance-none inline-block align-middle select-none flex-shrink-0 w-4 h-4 text-primary-400 border border-neutral-300 rounded-sm`}; + color-adjust: exact; + background-origin: border-box; + transition: all 75ms linear, box-shadow 25ms linear; + + &:checked { + ${tw`border-transparent bg-no-repeat bg-center`}; + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e"); + background-color: currentColor; + background-size: 100% 100%; + } + + &:focus { + ${tw`outline-none border-primary-300`}; + box-shadow: 0 0 0 1px rgba(9, 103, 210, 0.25); + } +`; + +const inputStyle = css` + // Reset to normal styling. + resize: none; + ${tw`appearance-none outline-none w-full min-w-0`}; + ${tw`p-3 border rounded text-sm transition-all duration-150`}; + ${tw`bg-neutral-600 border-neutral-500 hover:border-neutral-400 text-neutral-200 shadow-none`}; + + & + .input-help { + ${tw`mt-1 text-xs`}; + ${props => props.hasError ? tw`text-red-400` : tw`text-neutral-400`}; + } + + &:required, &:invalid { + ${tw`shadow-none`}; + } + + &:not(:disabled):not(:read-only):focus { + ${tw`shadow-md border-primary-400`}; + } + + &:disabled { + ${tw`opacity-75`}; + } + + ${props => props.isLight && light}; + ${props => props.hasError && tw`text-red-600 border-red-500 hover:border-red-600`}; +`; + +const Input = styled.input` + &:not([type="checkbox"]):not([type="radio"]) { + ${inputStyle}; + } + + &[type="checkbox"], &[type="radio"] { + ${checkboxStyle}; + + &[type="radio"] { + ${tw`rounded-full`}; + } + } +`; +const Textarea = styled.textarea`${inputStyle}`; + +export { Textarea }; +export default Input; diff --git a/resources/scripts/components/elements/InputError.tsx b/resources/scripts/components/elements/InputError.tsx index df5bc7369..3b810454b 100644 --- a/resources/scripts/components/elements/InputError.tsx +++ b/resources/scripts/components/elements/InputError.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import capitalize from 'lodash-es/capitalize'; import { FormikErrors, FormikTouched } from 'formik'; +import tw from 'twin.macro'; +import { capitalize } from '@/helpers'; interface Props { errors: FormikErrors; touched: FormikTouched; name: string; - children?: React.ReactNode; + children?: string | number | null | undefined; } const InputError = ({ errors, touched, name, children }: Props) => ( touched[name] && errors[name] ? -

+

{typeof errors[name] === 'string' ? capitalize(errors[name] as string) : @@ -19,9 +20,9 @@ const InputError = ({ errors, touched, name, children }: Props) => ( }

: - - {children} - + <> + {children ?

{children}

: null} + ); export default InputError; diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx index 1dbc3c84a..ce4843dff 100644 --- a/resources/scripts/components/elements/InputSpinner.tsx +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -1,20 +1,20 @@ import React from 'react'; import Spinner from '@/components/elements/Spinner'; -import { CSSTransition } from 'react-transition-group'; +import Fade from '@/components/elements/Fade'; +import tw from 'twin.macro'; const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( -
- + -
- +
+
- + {children}
); diff --git a/resources/scripts/components/elements/Label.tsx b/resources/scripts/components/elements/Label.tsx new file mode 100644 index 000000000..cfe16550b --- /dev/null +++ b/resources/scripts/components/elements/Label.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; + +const Label = styled.label<{ isLight?: boolean }>` + ${tw`block text-xs uppercase text-neutral-200 mb-2`}; + ${props => props.isLight && tw`text-neutral-700`}; +`; + +export default Label; diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index c37669b02..f242fbacc 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useMemo, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; -import { CSSTransition } from 'react-transition-group'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; import Spinner from '@/components/elements/Spinner'; -import classNames from 'classnames'; +import tw from 'twin.macro'; +import styled from 'styled-components/macro'; +import Fade from '@/components/elements/Fade'; export interface RequiredModalProps { visible: boolean; @@ -12,20 +13,39 @@ export interface RequiredModalProps { top?: boolean; } -type Props = RequiredModalProps & { +interface Props extends RequiredModalProps { dismissable?: boolean; closeOnEscape?: boolean; closeOnBackground?: boolean; showSpinnerOverlay?: boolean; - children: React.ReactNode; } -export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => { - const [render, setRender] = useState(visible); +const ModalMask = styled.div` + ${tw`fixed z-50 overflow-auto flex w-full inset-0`}; + background: rgba(0, 0, 0, 0.70); +`; + +const ModalContainer = styled.div<{ alignTop?: boolean }>` + ${tw`relative flex flex-col w-full m-auto`}; + max-height: calc(100vh - 8rem); + max-width: 50%; + // @todo max-w-screen-lg perhaps? + ${props => props.alignTop && 'margin-top: 10%'}; + + & > .close-icon { + ${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`}; + top: -2rem; + + &:hover {${tw`transform rotate-90`}} + } +`; + +const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { + const [ render, setRender ] = useState(visible); const isDismissable = useMemo(() => { return (dismissable || true) && !(showSpinnerOverlay || false); - }, [dismissable, showSpinnerOverlay]); + }, [ dismissable, showSpinnerOverlay ]); const handleEscapeEvent = (e: KeyboardEvent) => { if (isDismissable && closeOnEscape && e.key === 'Escape') { @@ -33,52 +53,47 @@ export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, } }; - useEffect(() => { - setRender(visible); - }, [visible]); + useEffect(() => setRender(visible), [ visible ]); useEffect(() => { window.addEventListener('keydown', handleEscapeEvent); return () => window.removeEventListener('keydown', handleEscapeEvent); - }, [render]); + }, [ render ]); return ( - onDismissed()} - > -
{ - if (isDismissable && closeOnBackground) { - e.stopPropagation(); - if (e.target === e.currentTarget) { - setRender(false); + + { + if (isDismissable && closeOnBackground) { + e.stopPropagation(); + if (e.target === e.currentTarget) { + setRender(false); + } } - } - }}> -
+ }} + > + {isDismissable && -
setRender(false)}> +
setRender(false)}>
} {showSpinnerOverlay &&
} -
+
{children}
-
-
- +
+ + ); }; + +export default Modal; diff --git a/resources/scripts/components/elements/PageContentBlock.tsx b/resources/scripts/components/elements/PageContentBlock.tsx index 87d9d3d34..f32c42ce2 100644 --- a/resources/scripts/components/elements/PageContentBlock.tsx +++ b/resources/scripts/components/elements/PageContentBlock.tsx @@ -1,26 +1,26 @@ 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'; -interface Props { - children: React.ReactNode; - className?: string; -} - -export default ({ className, children }: Props) => ( - +const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( + <> - + + {showFlashKey && + + } {children} - -

+ +

© 2015 - 2020  Pterodactyl Software @@ -29,3 +29,5 @@ export default ({ className, children }: Props) => ( ); + +export default PageContentBlock; diff --git a/resources/scripts/components/elements/ProgressBar.tsx b/resources/scripts/components/elements/ProgressBar.tsx index c4b867928..64ee732b7 100644 --- a/resources/scripts/components/elements/ProgressBar.tsx +++ b/resources/scripts/components/elements/ProgressBar.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; -import styled from 'styled-components'; +import styled from 'styled-components/macro'; import { useStoreActions, useStoreState } from 'easy-peasy'; import { randomInt } from '@/helpers'; import { CSSTransition } from 'react-transition-group'; +import tw from 'twin.macro'; const BarFill = styled.div` ${tw`h-full bg-cyan-400`}; @@ -60,10 +61,10 @@ export default () => { return (

diff --git a/resources/scripts/components/elements/Select.tsx b/resources/scripts/components/elements/Select.tsx new file mode 100644 index 000000000..7ca441b48 --- /dev/null +++ b/resources/scripts/components/elements/Select.tsx @@ -0,0 +1,36 @@ +import styled, { css } from 'styled-components/macro'; +import tw from 'twin.macro'; + +interface Props { + hideDropdownArrow?: boolean; +} + +const Select = styled.select` + ${tw`shadow-none block p-3 pr-8 rounded border w-full text-sm transition-colors duration-150 ease-linear`}; + + &, &:hover:not(:disabled), &:focus { + ${tw`outline-none`}; + } + + -webkit-appearance: none; + -moz-appearance: none; + background-size: 1rem; + background-repeat: no-repeat; + background-position-x: calc(100% - 0.75rem); + background-position-y: center; + + &::-ms-expand { + display: none; + } + + ${props => !props.hideDropdownArrow && css` + ${tw`bg-neutral-600 border-neutral-500 text-neutral-200`}; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='%23C3D1DF' d='M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z'/%3e%3c/svg%3e "); + + &:hover:not(:disabled), &:focus { + ${tw`border-neutral-400`}; + } + `}; +`; + +export default Select; diff --git a/resources/scripts/components/elements/Spinner.tsx b/resources/scripts/components/elements/Spinner.tsx index cc4476abf..127ab6524 100644 --- a/resources/scripts/components/elements/Spinner.tsx +++ b/resources/scripts/components/elements/Spinner.tsx @@ -1,31 +1,48 @@ import React from 'react'; -import classNames from 'classnames'; +import styled, { css, keyframes } from 'styled-components/macro'; +import tw from 'twin.macro'; -export type SpinnerSize = 'large' | 'normal' | 'tiny'; +export type SpinnerSize = 'small' | 'base' | 'large'; interface Props { size?: SpinnerSize; centered?: boolean; - className?: string; + isBlue?: boolean; } -const Spinner = ({ size, centered, className }: Props) => ( +const spin = keyframes` + to { transform: rotate(360deg); } +`; + +// noinspection CssOverwrittenProperties +const SpinnerComponent = styled.div` + ${tw`w-8 h-8`}; + border-width: 3px; + border-radius: 50%; + animation: ${spin} 1s cubic-bezier(0.55, 0.25, 0.25, 0.70) infinite; + + ${props => props.size === 'small' ? tw`w-4 h-4 border-2` : (props.size === 'large' ? css` + ${tw`w-16 h-16`}; + border-width: 6px; + ` : null)}; + + border-color: ${props => !props.isBlue ? 'rgba(255, 255, 255, 0.2)' : 'hsla(212, 92%, 43%, 0.2)'}; + border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'}; +`; + +const Spinner = ({ centered, ...props }: Props) => ( centered ? -
-
+
+
: -
+ ); +Spinner.DisplayName = 'Spinner'; export default Spinner; diff --git a/resources/scripts/components/elements/SpinnerOverlay.tsx b/resources/scripts/components/elements/SpinnerOverlay.tsx index 87f66bc66..58d17757e 100644 --- a/resources/scripts/components/elements/SpinnerOverlay.tsx +++ b/resources/scripts/components/elements/SpinnerOverlay.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import classNames from 'classnames'; -import { CSSTransition } from 'react-transition-group'; import Spinner, { SpinnerSize } from '@/components/elements/Spinner'; +import Fade from '@/components/elements/Fade'; +import tw from 'twin.macro'; interface Props { visible: boolean; @@ -11,17 +11,17 @@ interface Props { } const SpinnerOverlay = ({ size, fixed, visible, backgroundOpacity }: Props) => ( - +
-
+ ); export default SpinnerOverlay; diff --git a/resources/scripts/components/elements/SubNavigation.tsx b/resources/scripts/components/elements/SubNavigation.tsx new file mode 100644 index 000000000..83221e935 --- /dev/null +++ b/resources/scripts/components/elements/SubNavigation.tsx @@ -0,0 +1,31 @@ +import styled from 'styled-components/macro'; +import tw from 'twin.macro'; +// @ts-ignore +import config from '../../../../tailwind.config'; + +const SubNavigation = styled.div` + ${tw`w-full bg-neutral-700 shadow`}; + + & > div { + ${tw`flex items-center text-sm mx-auto px-2`}; + max-width: 1200px; + + & > a, & > div { + ${tw`inline-block py-3 px-4 text-neutral-300 no-underline transition-all duration-150`}; + + &:not(:first-of-type) { + ${tw`ml-2`}; + } + + &:active, &:hover { + ${tw`text-neutral-100`}; + } + + &:active, &:hover, &.active { + box-shadow: inset 0 -2px ${config.theme.colors.cyan['500']}; + } + } + } +`; + +export default SubNavigation; diff --git a/resources/scripts/components/elements/SuspenseSpinner.tsx b/resources/scripts/components/elements/SuspenseSpinner.tsx index 3c2c42623..3e7098cad 100644 --- a/resources/scripts/components/elements/SuspenseSpinner.tsx +++ b/resources/scripts/components/elements/SuspenseSpinner.tsx @@ -1,14 +1,8 @@ import React, { Suspense } from 'react'; import Spinner from '@/components/elements/Spinner'; -const SuspenseSpinner = ({ children }: { children?: React.ReactNode }) => ( - - -
- } - > +const SuspenseSpinner: React.FC = ({ children }) => ( + }> {children} ); diff --git a/resources/scripts/components/elements/Switch.tsx b/resources/scripts/components/elements/Switch.tsx index 606cc2281..5c4b1e9b3 100644 --- a/resources/scripts/components/elements/Switch.tsx +++ b/resources/scripts/components/elements/Switch.tsx @@ -1,7 +1,9 @@ import React, { useMemo } from 'react'; -import styled from 'styled-components'; +import styled from 'styled-components/macro'; import v4 from 'uuid/v4'; -import classNames from 'classnames'; +import tw from 'twin.macro'; +import Label from '@/components/elements/Label'; +import Input from '@/components/elements/Input'; const ToggleContainer = styled.div` ${tw`relative select-none w-12 leading-normal`}; @@ -47,10 +49,10 @@ const Switch = ({ name, label, description, defaultChecked, onChange, children } const uuid = useMemo(() => v4(), []); return ( -
- +
+ {children - || } - {(label || description) && -
+
{label && - + > + {label} + } {description && -

+

{description}

} diff --git a/resources/scripts/components/elements/TitledGreyBox.tsx b/resources/scripts/components/elements/TitledGreyBox.tsx index 7e5bb1619..3e83f8689 100644 --- a/resources/scripts/components/elements/TitledGreyBox.tsx +++ b/resources/scripts/components/elements/TitledGreyBox.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import tw from 'twin.macro'; interface Props { icon?: IconProp; @@ -10,17 +11,17 @@ interface Props { } const TitledGreyBox = ({ icon, title, children, className }: Props) => ( -
-
+
+
{typeof title === 'string' ? -

- {icon && }{title} +

+ {icon && }{title}

: title }
-
+
{children}
diff --git a/resources/scripts/components/screens/ScreenBlock.tsx b/resources/scripts/components/screens/ScreenBlock.tsx index cff1c5501..55e1e70a0 100644 --- a/resources/scripts/components/screens/ScreenBlock.tsx +++ b/resources/scripts/components/screens/ScreenBlock.tsx @@ -1,10 +1,10 @@ import React from 'react'; import PageContentBlock from '@/components/elements/PageContentBlock'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft'; -import { faSyncAlt } from '@fortawesome/free-solid-svg-icons/faSyncAlt'; -import classNames from 'classnames'; -import styled from 'styled-components'; +import { faArrowLeft, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; +import styled, { keyframes } from 'styled-components/macro'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; interface BaseProps { title: string; @@ -26,37 +26,35 @@ interface PropsWithBack extends BaseProps { type Props = PropsWithBack | PropsWithRetry; -const ActionButton = styled.button` +const spin = keyframes` + to { transform: rotate(360deg) } +`; + +const ActionButton = styled(Button)` ${tw`rounded-full w-8 h-8 flex items-center justify-center`}; &.hover\\:spin:hover { - animation: spin 2s linear infinite; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } + animation: ${spin} 2s linear infinite; } `; export default ({ title, image, message, onBack, onRetry }: Props) => ( -
-
+
+
{(typeof onBack === 'function' || typeof onRetry === 'function') && -
+
onRetry ? onRetry() : (onBack ? onBack() : null)} - className={classNames('btn btn-primary', { 'hover:spin': !!onRetry })} + className={onRetry ? 'hover:spin' : undefined} >
} - -

{title}

-

+ +

{title}

+

{message}

diff --git a/resources/scripts/components/screens/ServerError.tsx b/resources/scripts/components/screens/ServerError.tsx index 42e82e5a5..b05dd3f14 100644 --- a/resources/scripts/components/screens/ServerError.tsx +++ b/resources/scripts/components/screens/ServerError.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import styled from 'styled-components'; import ScreenBlock from '@/components/screens/ScreenBlock'; interface Props { diff --git a/resources/scripts/components/server/Console.tsx b/resources/scripts/components/server/Console.tsx index 54d6e9620..49fb59cf0 100644 --- a/resources/scripts/components/server/Console.tsx +++ b/resources/scripts/components/server/Console.tsx @@ -3,10 +3,10 @@ import { ITerminalOptions, Terminal } from 'xterm'; import * as TerminalFit from 'xterm/lib/addons/fit/fit'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import { ServerContext } from '@/state/server'; -import styled from 'styled-components'; -import Can from '@/components/elements/Can'; +import styled from 'styled-components/macro'; import { usePermissions } from '@/plugins/usePermissions'; -import classNames from 'classnames'; +import tw from 'twin.macro'; +import 'xterm/dist/xterm.css'; const theme = { background: 'transparent', @@ -55,7 +55,7 @@ export default () => { const useRef = useCallback(node => setTerminalElement(node), []); const terminal = useMemo(() => new Terminal({ ...terminalProps }), []); const { connected, instance } = ServerContext.useStoreState(state => state.socket); - const [ canSendCommands ] = usePermissions([ 'control.console']); + const [ canSendCommands ] = usePermissions([ 'control.console' ]); const handleConsoleOutput = (line: string, prelude = false) => terminal.writeln( (prelude ? TERMINAL_PRELUDE : '') + line.replace(/(?:\r\n|\r|\n)$/im, '') + '\u001b[0m', @@ -122,12 +122,13 @@ export default () => { }, [ connected, instance ]); return ( -
+
{
{canSendCommands && -
-
$
-
+
+
$
+
handleCommandKeydown(e)} />
diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index 16675b35e..8c9dd1ed5 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -1,47 +1,22 @@ import React, { lazy, useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faServer } from '@fortawesome/free-solid-svg-icons/faServer'; -import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle'; -import classNames from 'classnames'; -import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory'; -import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip'; -import { faHdd } from '@fortawesome/free-solid-svg-icons/faHdd'; +import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; import { bytesToHuman } from '@/helpers'; import SuspenseSpinner from '@/components/elements/SuspenseSpinner'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import Can from '@/components/elements/Can'; import PageContentBlock from '@/components/elements/PageContentBlock'; import ContentContainer from '@/components/elements/ContentContainer'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import StopOrKillButton from '@/components/server/StopOrKillButton'; -type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; +export type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; const ChunkedConsole = lazy(() => import(/* webpackChunkName: "console" */'@/components/server/Console')); const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/components/server/StatGraphs')); -const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => { - const [ clicked, setClicked ] = useState(false); - const status = ServerContext.useStoreState(state => state.status.value); - - useEffect(() => { - setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state); - }, [ status ]); - - return ( - - ); -}; - export default () => { const [ memory, setMemory ] = useState(0); const [ cpu, setCpu ] = useState(0); @@ -81,58 +56,45 @@ export default () => { }; }, [ instance, connected ]); - const disklimit = server.limits.disk != 0 ? bytesToHuman(server.limits.disk * 1000 * 1000) : "Unlimited"; - const memorylimit = server.limits.memory != 0 ? bytesToHuman(server.limits.memory * 1000 * 1000) : "Unlimited"; + const disklimit = server.limits.disk ? bytesToHuman(server.limits.disk * 1000 * 1000) : 'Unlimited'; + const memorylimit = server.limits.memory ? bytesToHuman(server.limits.memory * 1000 * 1000) : 'Unlimited'; return ( - -
+ +
-

+

 {status}

-

- -  {cpu.toFixed(2)} % +

+ {cpu.toFixed(2)}%

-

- -  {bytesToHuman(memory)} - / {memorylimit} -

-

- -  {bytesToHuman(disk)} - / {disklimit} +

+ {bytesToHuman(memory)} + / {memorylimit} +

+

+  {bytesToHuman(disk)} + / {disklimit}

{!server.isInstalling ? - -
+ +
- + - + sendPowerCommand(action)}/> @@ -159,9 +123,9 @@ export default () => {
: -
+
-

+

This server is currently running its installation process and most actions are unavailable.

@@ -169,7 +133,7 @@ export default () => {
}
-
+
diff --git a/resources/scripts/components/server/StatGraphs.tsx b/resources/scripts/components/server/StatGraphs.tsx index a7595b947..44bd59f10 100644 --- a/resources/scripts/components/server/StatGraphs.tsx +++ b/resources/scripts/components/server/StatGraphs.tsx @@ -2,12 +2,12 @@ import React, { useCallback, useEffect, useState } from 'react'; import Chart, { ChartConfiguration } from 'chart.js'; import { ServerContext } from '@/state/server'; import { bytesToMegabytes } from '@/helpers'; -import merge from 'lodash-es/merge'; +import merge from 'deepmerge'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; -import { faMemory } from '@fortawesome/free-solid-svg-icons/faMemory'; -import { faMicrochip } from '@fortawesome/free-solid-svg-icons/faMicrochip'; +import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons'; +import tw from 'twin.macro'; -const chartDefaults: ChartConfiguration = { +const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({ type: 'line', options: { legend: { @@ -45,21 +45,17 @@ const chartDefaults: ChartConfiguration = { zeroLineColor: 'rgba(15, 178, 184, 0.45)', zeroLineWidth: 3, }, - ticks: { + ticks: merge(ticks || {}, { fontSize: 10, fontFamily: '"IBM Plex Mono", monospace', fontColor: 'rgb(229, 232, 235)', min: 0, beginAtZero: true, maxTicksLimit: 5, - }, + }), } ], }, }, -}; - -const createDefaultChart = (ctx: CanvasRenderingContext2D, options?: ChartConfiguration): Chart => new Chart(ctx, { - ...merge({}, chartDefaults, options), data: { labels: Array(20).fill(''), datasets: [ @@ -84,18 +80,12 @@ export default () => { return; } - setMemory(createDefaultChart(node.getContext('2d')!, { - options: { - scales: { - yAxes: [ { - ticks: { - callback: (value) => `${value}Mb `, - suggestedMax: limits.memory, - }, - } ], - }, - }, - })); + setMemory( + new Chart(node.getContext('2d')!, chartDefaults({ + callback: (value) => `${value}Mb `, + suggestedMax: limits.memory, + })) + ); }, []); const cpuRef = useCallback<(node: HTMLCanvasElement | null) => void>(node => { @@ -103,17 +93,11 @@ export default () => { return; } - setCpu(createDefaultChart(node.getContext('2d')!, { - options: { - scales: { - yAxes: [ { - ticks: { - callback: (value) => `${value}% `, - }, - } ], - }, - }, - })); + setCpu( + new Chart(node.getContext('2d')!, chartDefaults({ + callback: (value) => `${value}%`, + })), + ); }, []); const statsListener = (data: string) => { @@ -157,21 +141,21 @@ export default () => { }, [ instance, connected, memory, cpu ]); return ( -
- +
+ {status !== 'offline' ? : -

+

Server is offline.

}
- + {status !== 'offline' ? : -

+

Server is offline.

} diff --git a/resources/scripts/components/server/StopOrKillButton.tsx b/resources/scripts/components/server/StopOrKillButton.tsx new file mode 100644 index 000000000..b9daed85b --- /dev/null +++ b/resources/scripts/components/server/StopOrKillButton.tsx @@ -0,0 +1,30 @@ +import React, { useEffect, useState } from 'react'; +import { ServerContext } from '@/state/server'; +import { PowerAction } from '@/components/server/ServerConsole'; +import Button from '@/components/elements/Button'; + +const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => { + const [ clicked, setClicked ] = useState(false); + const status = ServerContext.useStoreState(state => state.status.value); + + useEffect(() => { + setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state); + }, [ status ]); + + return ( + + ); +}; + +export default StopOrKillButton; diff --git a/resources/scripts/components/server/WebsocketHandler.tsx b/resources/scripts/components/server/WebsocketHandler.tsx index 479304d37..fe43e56d5 100644 --- a/resources/scripts/components/server/WebsocketHandler.tsx +++ b/resources/scripts/components/server/WebsocketHandler.tsx @@ -5,6 +5,7 @@ import getWebsocketToken from '@/api/server/getWebsocketToken'; import ContentContainer from '@/components/elements/ContentContainer'; import { CSSTransition } from 'react-transition-group'; import Spinner from '@/components/elements/Spinner'; +import tw from 'twin.macro'; export default () => { const server = ServerContext.useStoreState(state => state.server.data); @@ -66,12 +67,12 @@ export default () => { return ( error ? - -
- - -

- We're having some trouble connecting to your server, please wait... + +

+ + +

+ We're having some trouble connecting to your server, please wait...

diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 1dbe3070e..669f04e84 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -10,6 +10,7 @@ import FlashMessageRender from '@/components/FlashMessageRender'; import BackupRow from '@/components/server/backups/BackupRow'; import { ServerContext } from '@/state/server'; import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; export default () => { const { uuid, featureLimits } = useServer(); @@ -31,14 +32,14 @@ export default () => { }, []); if (backups.length === 0 && loading) { - return ; + return ; } return ( - + {!backups.length ? -

+

There are no backups stored for this server.

: @@ -46,7 +47,7 @@ export default () => { {backups.map((backup, index) => 0 ? tw`mt-2` : undefined} />)}
} @@ -57,12 +58,12 @@ export default () => { } {(featureLimits.backups > 0 && backups.length > 0) && -

+

{backups.length} of {featureLimits.backups} backups have been created for this server.

} {featureLimits.backups > 0 && featureLimits.backups !== backups.length && -
+
} diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 16b636e51..54a45b9d3 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -1,11 +1,8 @@ import React, { useState } from 'react'; import { ServerBackup } from '@/api/server/backups/getServerBackups'; -import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH'; +import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; -import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt'; -import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; -import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'; import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; import { httpErrorToHuman } from '@/api/http'; import useFlash from '@/plugins/useFlash'; @@ -16,6 +13,7 @@ import deleteBackup from '@/api/server/backups/deleteBackup'; import { ServerContext } from '@/state/server'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; import Can from '@/components/elements/Can'; +import tw from 'twin.macro'; interface Props { backup: ServerBackup; @@ -61,8 +59,8 @@ export default ({ backup }: Props) => { <> {visible && setVisible(false)} checksum={backup.sha256Hash} /> @@ -79,32 +77,32 @@ export default ({ backup }: Props) => { be recovered once deleted. } - + ( )} > -
+
doDownload()}> - - Download + + Download setVisible(true)}> - - Checksum + + Checksum - setDeleteVisible(true)}> - - Delete + setDeleteVisible(true)}> + + Delete
diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 41183504a..2a7b625bc 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -1,22 +1,16 @@ -import React, { useState } from 'react'; +import React from 'react'; import { ServerBackup } from '@/api/server/backups/getServerBackups'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive'; -import format from 'date-fns/format'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import { format, formatDistanceToNow } from 'date-fns'; import Spinner from '@/components/elements/Spinner'; -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import { bytesToHuman } from '@/helpers'; import Can from '@/components/elements/Can'; -import useServer from '@/plugins/useServer'; -import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; -import useFlash from '@/plugins/useFlash'; -import { httpErrorToHuman } from '@/api/http'; import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import { ServerContext } from '@/state/server'; import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; -import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH'; +import tw from 'twin.macro'; +import GreyRowBox from '@/components/elements/GreyRowBox'; interface Props { backup: ServerBackup; @@ -41,38 +35,38 @@ export default ({ backup, className }: Props) => { }); return ( -
-
+ +
{backup.completedAt ? - + : - + }
-
-

+

+

{backup.name} {backup.completedAt && - {bytesToHuman(backup.bytes)} + {bytesToHuman(backup.bytes)} }

-

+

{backup.uuid}

-
+

- {distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} + {formatDistanceToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}

-

Created

+

Created

-
+
{!backup.completedAt ? -
+
: @@ -80,6 +74,6 @@ export default ({ backup, className }: Props) => { }
-
+ ); }; diff --git a/resources/scripts/components/server/backups/ChecksumModal.tsx b/resources/scripts/components/server/backups/ChecksumModal.tsx index f400da75b..91b275904 100644 --- a/resources/scripts/components/server/backups/ChecksumModal.tsx +++ b/resources/scripts/components/server/backups/ChecksumModal.tsx @@ -1,14 +1,15 @@ import React from 'react'; import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import tw from 'twin.macro'; const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => ( -

Verify file checksum

-

+

Verify file checksum

+

The SHA256 checksum of this file is:

-
-            {checksum}
+        
+            {checksum}
         
); diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 9e0e8421c..7a04f1041 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -10,6 +10,9 @@ import createServerBackup from '@/api/server/backups/createServerBackup'; import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; import { ServerContext } from '@/state/server'; +import Button from '@/components/elements/Button'; +import tw from 'twin.macro'; +import { Textarea } from '@/components/elements/Input'; interface Values { name: string; @@ -21,17 +24,17 @@ const ModalContent = ({ ...props }: RequiredModalProps) => { return ( -
- -

Create server backup

-
+ + +

Create server backup

+
-
+
{ prefixing the path with an exclamation point. `} > - +
-
- +
@@ -99,18 +95,15 @@ export default () => { })} > setVisible(false)} /> } - + ); }; diff --git a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx index cf5b8c63d..2b035ce2c 100644 --- a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx +++ b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx @@ -9,6 +9,8 @@ import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; import useFlash from '@/plugins/useFlash'; import useServer from '@/plugins/useServer'; +import Button from '@/components/elements/Button'; +import tw from 'twin.macro'; interface Values { databaseName: string; @@ -48,7 +50,7 @@ export default () => { }; return ( - + <> { setVisible(false); }} > - -

Create new database

-
+ +

Create new database

+ { label={'Database Name'} description={'A descriptive name for your database instance.'} /> -
+
{ description={'Where connections should be allowed from. Use % for wildcards.'} />
-
- - + +
) } - - + + ); }; diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx index 9adc38519..4cad11611 100644 --- a/resources/scripts/components/server/databases/DatabaseRow.tsx +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -1,9 +1,6 @@ import React, { useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faDatabase } from '@fortawesome/free-solid-svg-icons/faDatabase'; -import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; -import { faEye } from '@fortawesome/free-solid-svg-icons/faEye'; -import classNames from 'classnames'; +import { faDatabase, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; import Modal from '@/components/elements/Modal'; import { Form, Formik, FormikHelpers } from 'formik'; import Field from '@/components/elements/Field'; @@ -17,6 +14,11 @@ import Can from '@/components/elements/Can'; import { ServerDatabase } from '@/api/server/getServerDatabases'; import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import Label from '@/components/elements/Label'; +import Input from '@/components/elements/Input'; +import GreyRowBox from '@/components/elements/GreyRowBox'; interface Props { database: ServerDatabase; @@ -51,13 +53,14 @@ export default ({ database, className }: Props) => { addError({ key: 'database:delete', message: httpErrorToHuman(error) }); }); }; - + return ( - + <> { ({ isSubmitting, isValid, resetForm }) => ( @@ -70,13 +73,13 @@ export default ({ database, className }: Props) => { resetForm(); }} > - -

Confirm database deletion

-

+ +

Confirm database deletion

+

Deleting a database is a permanent action, it cannot be undone. This will permanetly delete the {database.name} database and remove all associated data.

-
+ { label={'Confirm Database Name'} description={'Enter the database name to confirm deletion.'} /> -
- - +
@@ -106,62 +110,61 @@ export default ({ database, className }: Props) => { }
setConnectionVisible(false)}> - -

Database connection details

+ +

Database connection details

- - + +
-
- - + +
-
+
- +
-
-
- + +
+
-
-

{database.name}

+
+

{database.name}

-
-

{database.connectionString}

-

Endpoint

+
+

{database.connectionString}

+

Endpoint

-
-

{database.allowConnectionsFrom}

-

Connections from

+
+

{database.allowConnectionsFrom}

+

Connections from

-
-

{database.username}

-

Username

+
+

{database.username}

+

Username

-
- +
+ - +
-
- + + ); }; diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 9213347f0..486072598 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -5,12 +5,13 @@ import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; import DatabaseRow from '@/components/server/databases/DatabaseRow'; import Spinner from '@/components/elements/Spinner'; -import { CSSTransition } from 'react-transition-group'; import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton'; import Can from '@/components/elements/Can'; import useFlash from '@/plugins/useFlash'; import useServer from '@/plugins/useServer'; import PageContentBlock from '@/components/elements/PageContentBlock'; +import tw from 'twin.macro'; +import Fade from '@/components/elements/Fade'; export default () => { const { uuid, featureLimits } = useServer(); @@ -35,11 +36,11 @@ export default () => { return ( - + {(!databases.length && loading) ? - + : - + <> {databases.length > 0 ? databases.map((database, index) => ( @@ -50,28 +51,29 @@ export default () => { /> )) : -

+

{featureLimits.databases > 0 ? - `It looks like you have no databases.` + 'It looks like you have no databases.' : - `Databases cannot be created for this server.` + 'Databases cannot be created for this server.' }

} {(featureLimits.databases > 0 && databases.length > 0) && -

- {databases.length} of {featureLimits.databases} databases have been allocated to this server. +

+ {databases.length} of {featureLimits.databases} databases have been allocated to this + server.

} {featureLimits.databases > 0 && featureLimits.databases !== databases.length && -
+
} - + } ); diff --git a/resources/scripts/components/server/databases/RotatePasswordButton.tsx b/resources/scripts/components/server/databases/RotatePasswordButton.tsx index fdb31a1e2..41c116006 100644 --- a/resources/scripts/components/server/databases/RotatePasswordButton.tsx +++ b/resources/scripts/components/server/databases/RotatePasswordButton.tsx @@ -6,6 +6,7 @@ import { ServerContext } from '@/state/server'; import { ServerDatabase } from '@/api/server/getServerDatabases'; import { httpErrorToHuman } from '@/api/http'; import Button from '@/components/elements/Button'; +import tw from 'twin.macro'; export default ({ databaseId, onUpdate }: { databaseId: string; @@ -38,7 +39,7 @@ export default ({ databaseId, onUpdate }: { }; return ( - ); diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 5c1b19d06..c03e5396e 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -1,192 +1,134 @@ -import React, { createRef, useEffect, useState } from 'react'; +import React, { useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH'; -import { CSSTransition } from 'react-transition-group'; -import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt'; -import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; -import { faFileDownload } from '@fortawesome/free-solid-svg-icons/faFileDownload'; -import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy'; -import { faLevelUpAlt } from '@fortawesome/free-solid-svg-icons/faLevelUpAlt'; +import { + faCopy, + faEllipsisH, + faFileDownload, + faLevelUpAlt, + faPencilAlt, + faTrashAlt, + IconDefinition, +} from '@fortawesome/free-solid-svg-icons'; import RenameFileModal from '@/components/server/files/RenameFileModal'; import { ServerContext } from '@/state/server'; import { join } from 'path'; import deleteFile from '@/api/server/files/deleteFile'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import copyFile from '@/api/server/files/copyFile'; -import { httpErrorToHuman } from '@/api/http'; import Can from '@/components/elements/Can'; import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl'; import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; +import tw from 'twin.macro'; +import { FileObject } from '@/api/server/files/loadDirectory'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import DropdownMenu from '@/components/elements/DropdownMenu'; +import styled from 'styled-components/macro'; +import useEventListener from '@/plugins/useEventListener'; type ModalType = 'rename' | 'move'; -export default ({ uuid }: { uuid: string }) => { - const menu = createRef(); - const menuButton = createRef(); - const [ menuVisible, setMenuVisible ] = useState(false); +const StyledRow = styled.div<{ $danger?: boolean }>` + ${tw`p-2 flex items-center rounded`}; + ${props => props.$danger ? tw`hover:bg-red-100 hover:text-red-700` : tw`hover:bg-neutral-100 hover:text-neutral-700`}; +`; + +interface RowProps extends React.HTMLAttributes { + icon: IconDefinition; + title: string; + $danger?: boolean; +} + +const Row = ({ icon, title, ...props }: RowProps) => ( + + + {title} + +); + +export default ({ file }: { file: FileObject }) => { + const onClickRef = useRef(null); const [ showSpinner, setShowSpinner ] = useState(false); const [ modal, setModal ] = useState(null); - const [ posX, setPosX ] = useState(0); - const server = useServer(); - const { addError, clearFlashes } = useFlash(); - - const file = ServerContext.useStoreState(state => state.files.contents.find(file => file.uuid === uuid)); + const { uuid } = useServer(); + const { mutate } = useFileManagerSwr(); + const { clearAndAddHttpError, clearFlashes } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); - const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files); - if (!file) { - return null; - } - - const windowListener = (e: MouseEvent) => { - if (e.button === 2 || !menuVisible || !menu.current) { - return; + useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => { + if (onClickRef.current) { + onClickRef.current.triggerMenu(e.detail); } - - if (e.target === menu.current || menu.current.contains(e.target as Node)) { - return; - } - - if (e.target !== menu.current && !menu.current.contains(e.target as Node)) { - setMenuVisible(false); - } - }; + }); const doDeletion = () => { - setShowSpinner(true); clearFlashes('files'); - deleteFile(server.uuid, join(directory, file.name)) - .then(() => removeFile(uuid)) - .catch(error => { - console.error('Error while attempting to delete a file.', error); - addError({ key: 'files', message: httpErrorToHuman(error) }); - setShowSpinner(false); - }); + + // For UI speed, immediately remove the file from the listing before calling the deletion function. + // If the delete actually fails, we'll fetch the current directory contents again automatically. + mutate(files => files.filter(f => f.uuid !== file.uuid), false); + + deleteFile(uuid, join(directory, file.name)).catch(error => { + mutate(); + clearAndAddHttpError({ key: 'files', error }); + }); }; const doCopy = () => { setShowSpinner(true); clearFlashes('files'); - copyFile(server.uuid, join(directory, file.name)) - .then(() => getDirectoryContents(directory)) + + copyFile(uuid, join(directory, file.name)) + .then(() => mutate()) .catch(error => { - console.error('Error while attempting to copy file.', error); - addError({ key: 'files', message: httpErrorToHuman(error) }); setShowSpinner(false); + clearAndAddHttpError({ key: 'files', error }); }); }; const doDownload = () => { setShowSpinner(true); clearFlashes('files'); - getFileDownloadUrl(server.uuid, join(directory, file.name)) + + getFileDownloadUrl(uuid, join(directory, file.name)) .then(url => { // @ts-ignore window.location = url; }) - .catch(error => { - console.error(error); - addError({ key: 'files', message: httpErrorToHuman(error) }); - }) + .catch(error => clearAndAddHttpError({ key: 'files', error })) .then(() => setShowSpinner(false)); }; - useEffect(() => { - menuVisible - ? document.addEventListener('click', windowListener) - : document.removeEventListener('click', windowListener); - - if (menuVisible && menu.current) { - menu.current.setAttribute( - 'style', `margin-top: -0.35rem; left: ${Math.round(posX - menu.current.clientWidth)}px`, - ); - } - }, [ menuVisible ]); - - useEffect(() => () => { - document.removeEventListener('click', windowListener); - }, []); - return ( -
-
{ - e.preventDefault(); - if (!menuVisible) { - setPosX(e.clientX); - } - setModal(null); - setMenuVisible(!menuVisible); - }} - > - - { - setModal(null); - setMenuVisible(false); - }} - /> - -
- -
{ - e.stopPropagation(); - setMenuVisible(false); - }} - className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'} - > - -
setModal('rename')} - className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'} - > - - Rename -
-
setModal('move')} - className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'} - > - - Move -
-
- -
doCopy()} - className={'hover:text-neutral-700 p-2 flex items-center hover:bg-neutral-100 rounded'} - > - - Copy -
-
-
doDownload()} - > - - Download -
- -
doDeletion()} - className={'hover:text-red-700 p-2 flex items-center hover:bg-red-100 rounded'} - > - - Delete -
-
+ ( +
+ + setModal(null)} + /> +
- -
+ )} + > + + setModal('rename')} icon={faPencilAlt} title={'Rename'}/> + setModal('move')} icon={faLevelUpAlt} title={'Move'}/> + + {file.isFile && + + + + } + + + + + ); }; diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index f24e7bc60..fdde9bb1b 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -1,30 +1,33 @@ import React, { lazy, useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import getFileContents from '@/api/server/files/getFileContents'; -import useRouter from 'use-react-router'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import saveFileContents from '@/api/server/files/saveFileContents'; import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs'; -import { useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import FileNameModal from '@/components/server/files/FileNameModal'; import Can from '@/components/elements/Can'; import FlashMessageRender from '@/components/FlashMessageRender'; import PageContentBlock from '@/components/elements/PageContentBlock'; import ServerError from '@/components/screens/ServerError'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor')); export default () => { const [ error, setError ] = useState(''); const { action } = useParams(); - const { history, location: { hash } } = useRouter(); const [ loading, setLoading ] = useState(action === 'edit'); const [ content, setContent ] = useState(''); const [ modalVisible, setModalVisible ] = useState(false); + const history = useHistory(); + const { hash } = useLocation(); + const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); @@ -81,16 +84,17 @@ export default () => { return ( - - - {(name || hash.replace(/^#/, '')).endsWith('.pteroignore') && -
-

- You're editing a .pteroignore file. + + + {hash.replace(/^#/, '').endsWith('.pteroignore') && +

+

+ You're editing + a .pteroignore file. Any files or directories listed in here will be excluded from backups. Wildcards are supported by - using an asterisk (*). You can + using an asterisk (*). You can negate a prior rule by prepending an exclamation point - (!). + (!).

} @@ -102,7 +106,7 @@ export default () => { save(name); }} /> -
+
{ onContentSaved={() => save()} />
-
+
{action === 'edit' ? - + : - + }
diff --git a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx index c125dd2c2..fba1938ea 100644 --- a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx +++ b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import { NavLink } from 'react-router-dom'; import { cleanDirectoryPath } from '@/helpers'; +import tw from 'twin.macro'; interface Props { withinFileEditor?: boolean; @@ -32,11 +33,11 @@ export default ({ withinFileEditor, isNewFile }: Props) => { }); return ( -
- /home/ +
+ /home/ container / @@ -46,18 +47,18 @@ export default ({ withinFileEditor, isNewFile }: Props) => { {crumb.name} / : - {crumb.name} + {crumb.name} )) } {file && - {decodeURIComponent(file)} + {decodeURIComponent(file)} }
diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 2e0298dce..eeed24078 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -1,8 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import FlashMessageRender from '@/components/FlashMessageRender'; -import { ServerContext } from '@/state/server'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import React, { useEffect } from 'react'; import { httpErrorToHuman } from '@/api/http'; import { CSSTransition } from 'react-transition-group'; import Spinner from '@/components/elements/Spinner'; @@ -10,11 +6,15 @@ import FileObjectRow from '@/components/server/files/FileObjectRow'; import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs'; import { FileObject } from '@/api/server/files/loadDirectory'; import NewDirectoryButton from '@/components/server/files/NewDirectoryButton'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Can from '@/components/elements/Can'; import PageContentBlock from '@/components/elements/PageContentBlock'; import ServerError from '@/components/screens/ServerError'; -import useRouter from 'use-react-router'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import useServer from '@/plugins/useServer'; +import { ServerContext } from '@/state/server'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; const sortFiles = (files: FileObject[]): FileObject[] => { return files.sort((a, b) => a.name.localeCompare(b.name)) @@ -22,93 +22,72 @@ const sortFiles = (files: FileObject[]): FileObject[] => { }; export default () => { - const [ error, setError ] = useState(''); - const [ loading, setLoading ] = useState(true); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); - const { id } = ServerContext.useStoreState(state => state.server.data!); - const { contents: files } = ServerContext.useStoreState(state => state.files); - const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files); - - const loadContents = () => { - setError(''); - clearFlashes(); - setLoading(true); - getDirectoryContents(window.location.hash) - .then(() => setLoading(false)) - .catch(error => { - console.error(error.message, { error }); - setError(httpErrorToHuman(error)); - }); - }; + const { id } = useServer(); + const { hash } = useLocation(); + const { data: files, error, mutate } = useFileManagerSwr(); + const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); useEffect(() => { - loadContents(); - }, []); + // We won't automatically mutate the store when the component re-mounts, otherwise because of + // my (horrible) programming this fires off way more than we intend it to. + mutate(); + + setDirectory(hash.length > 0 ? hash : '/'); + }, [ hash ]); if (error) { return ( - loadContents()} - /> + mutate()}/> ); } return ( - - - - - { - loading ? - - : - - {!files.length ? -

- This directory seems to be empty. -

- : - - -
- {files.length > 250 ? - -
-

- This directory is too large to display in the browser, - limiting the output to the first 250 files. -

-
- { - sortFiles(files.slice(0, 250)).map(file => ( - - )) - } -
- : - sortFiles(files).map(file => ( - - )) - } + + + { + !files ? + + : + <> + {!files.length ? +

+ This directory seems to be empty. +

+ : + + +
+ {files.length > 250 && +
+

+ This directory is too large to display in the browser, + limiting the output to the first 250 files. +

- - - } - -
- - - New File - -
-
- - } - + } + { + sortFiles(files.slice(0, 250)).map(file => ( + + )) + } +
+
+
+ } + +
+ + +
+
+ + }
); }; diff --git a/resources/scripts/components/server/files/FileNameModal.tsx b/resources/scripts/components/server/files/FileNameModal.tsx index 9ce60086e..b607e3b76 100644 --- a/resources/scripts/components/server/files/FileNameModal.tsx +++ b/resources/scripts/components/server/files/FileNameModal.tsx @@ -5,6 +5,8 @@ import { object, string } from 'yup'; import Field from '@/components/elements/Field'; import { ServerContext } from '@/state/server'; import { join } from 'path'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; type Props = RequiredModalProps & { onFileNamed: (name: string) => void; @@ -44,12 +46,10 @@ export default ({ onFileNamed, onDismissed, ...props }: Props) => { name={'fileName'} label={'File Name'} description={'Enter the name that this file should be saved as.'} - autoFocus={true} + autoFocus /> -
- +
+
diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index c393ae35f..2f097e1b9 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -1,75 +1,83 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFileImport } from '@fortawesome/free-solid-svg-icons/faFileImport'; -import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt'; -import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder'; +import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; import { bytesToHuman, cleanDirectoryPath } from '@/helpers'; -import differenceInHours from 'date-fns/difference_in_hours'; -import format from 'date-fns/format'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; -import React from 'react'; +import { differenceInHours, format, formatDistanceToNow } from 'date-fns'; +import React, { memo } from 'react'; import { FileObject } from '@/api/server/files/loadDirectory'; import FileDropdownMenu from '@/components/server/files/FileDropdownMenu'; import { ServerContext } from '@/state/server'; -import { NavLink } from 'react-router-dom'; -import useRouter from 'use-react-router'; +import { NavLink, useHistory, useRouteMatch } from 'react-router-dom'; +import tw from 'twin.macro'; +import isEqual from 'react-fast-compare'; +import styled from 'styled-components/macro'; -export default ({ file }: { file: FileObject }) => { +const Row = styled.div` + ${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`}; +`; + +const FileObjectRow = ({ file }: { file: FileObject }) => { const directory = ServerContext.useStoreState(state => state.files.directory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); - const { match, history } = useRouter(); + + const history = useHistory(); + const match = useRouteMatch(); + + const onRowClick = (e: React.MouseEvent) => { + // Don't rely on the onClick to work with the generated URL. Because of the way this + // component re-renders you'll get redirected into a nested directory structure since + // it'll cause the directory variable to update right away when you click. + // + // Just trust me future me, leave this be. + if (!file.isFile) { + e.preventDefault(); + + history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`); + setDirectory(`${directory}/${file.name}`); + } + }; return ( -
{ + e.preventDefault(); + window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); + }} > { - // Don't rely on the onClick to work with the generated URL. Because of the way this - // component re-renders you'll get redirected into a nested directory structure since - // it'll cause the directory variable to update right away when you click. - // - // Just trust me future me, leave this be. - if (!file.isFile) { - e.preventDefault(); - - history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`); - setDirectory(`${directory}/${file.name}`); - } - }} + css={tw`flex flex-1 text-neutral-300 no-underline p-3`} + onClick={onRowClick} > -
+
{file.isFile ? : }
-
+
{file.name}
{file.isFile && -
+
{bytesToHuman(file.size)}
}
{Math.abs(differenceInHours(file.modifiedAt, new Date())) > 48 ? - format(file.modifiedAt, 'MMM Do, YYYY h:mma') + format(file.modifiedAt, 'MMM do, yyyy h:mma') : - distanceInWordsToNow(file.modifiedAt, { addSuffix: true }) + formatDistanceToNow(file.modifiedAt, { addSuffix: true }) }
- -
+ + ); }; + +export default memo(FileObjectRow, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file)); diff --git a/resources/scripts/components/server/files/NewDirectoryButton.tsx b/resources/scripts/components/server/files/NewDirectoryButton.tsx index 842cddf8a..d1f23c022 100644 --- a/resources/scripts/components/server/files/NewDirectoryButton.tsx +++ b/resources/scripts/components/server/files/NewDirectoryButton.tsx @@ -7,6 +7,13 @@ import { join } from 'path'; import { object, string } from 'yup'; import createDirectory from '@/api/server/files/createDirectory'; import v4 from 'uuid/v4'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import { mutate } from 'swr'; +import useServer from '@/plugins/useServer'; +import { FileObject } from '@/api/server/files/loadDirectory'; +import { useLocation } from 'react-router'; +import useFlash from '@/plugins/useFlash'; interface Values { directoryName: string; @@ -16,37 +23,44 @@ const schema = object().shape({ directoryName: string().required('A valid directory name must be provided.'), }); -export default () => { - const [ visible, setVisible ] = useState(false); - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const directory = ServerContext.useStoreState(state => state.files.directory); - const pushFile = ServerContext.useStoreActions(actions => actions.files.pushFile); +const generateDirectoryData = (name: string): FileObject => ({ + uuid: v4(), + name: name, + mode: '0644', + size: 0, + isFile: false, + isEditable: false, + isSymlink: false, + mimetype: '', + createdAt: new Date(), + modifiedAt: new Date(), +}); - const submit = (values: Values, { setSubmitting }: FormikHelpers) => { - createDirectory(uuid, directory, values.directoryName) +export default () => { + const { uuid } = useServer(); + const { hash } = useLocation(); + const { clearAndAddHttpError } = useFlash(); + const [ visible, setVisible ] = useState(false); + const directory = ServerContext.useStoreState(state => state.files.directory); + + const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers) => { + createDirectory(uuid, directory, directoryName) .then(() => { - pushFile({ - uuid: v4(), - name: values.directoryName, - mode: '0644', - size: 0, - isFile: false, - isEditable: false, - isSymlink: false, - mimetype: '', - createdAt: new Date(), - modifiedAt: new Date(), - }); + mutate( + `${uuid}:files:${hash}`, + (data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ], + ); setVisible(false); }) .catch(error => { console.error(error); setSubmitting(false); + clearAndAddHttpError({ key: 'files', error }); }); }; return ( - + <> { resetForm(); }} > -
+ -

- This directory will be created as +

+ This directory will be created as  /home/container/ - + {decodeURIComponent( join(directory, values.directoryName).replace(/^(\.\.\/|\/)+/, ''), )}

-
- +
)}
- -
+ + ); }; diff --git a/resources/scripts/components/server/files/RenameFileModal.tsx b/resources/scripts/components/server/files/RenameFileModal.tsx index 41a0a0fdc..d7b9d3e8a 100644 --- a/resources/scripts/components/server/files/RenameFileModal.tsx +++ b/resources/scripts/components/server/files/RenameFileModal.tsx @@ -6,7 +6,11 @@ import { join } from 'path'; import renameFile from '@/api/server/files/renameFile'; import { ServerContext } from '@/state/server'; import { FileObject } from '@/api/server/files/loadDirectory'; -import classNames from 'classnames'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import useServer from '@/plugins/useServer'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import useFlash from '@/plugins/useFlash'; interface FormikValues { name: string; @@ -15,47 +19,44 @@ interface FormikValues { type Props = RequiredModalProps & { file: FileObject; useMoveTerminology?: boolean }; export default ({ file, useMoveTerminology, ...props }: Props) => { - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { uuid } = useServer(); + const { mutate } = useFileManagerSwr(); + const { clearAndAddHttpError } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); - const { pushFile, removeFile } = ServerContext.useStoreActions(actions => actions.files); - const submit = (values: FormikValues, { setSubmitting }: FormikHelpers) => { + const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers) => { + const len = name.split('/').length; + if (!useMoveTerminology && len === 1) { + // Rename the file within this directory. + mutate(files => files.map(f => f.uuid === file.uuid ? { ...f, name } : f), false); + } else if ((useMoveTerminology || len > 1) && file.uuid.length) { + // Remove the file from this directory since they moved it elsewhere. + mutate(files => files.filter(f => f.uuid !== file.uuid), false); + } + const renameFrom = join(directory, file.name); - const renameTo = join(directory, values.name); - + const renameTo = join(directory, name); renameFile(uuid, { renameFrom, renameTo }) - .then(() => { - if (!useMoveTerminology && values.name.split('/').length === 1) { - pushFile({ ...file, name: values.name }); - } - - if ((useMoveTerminology || values.name.split('/').length > 1) && file.uuid.length > 0) { - removeFile(file.uuid); - } - - props.onDismissed(); - }) + .then(() => props.onDismissed()) .catch(error => { + mutate(); setSubmitting(false); - console.error(error); + clearAndAddHttpError({ key: 'files', error }); }); }; return ( - + {({ isSubmitting, values }) => ( -
+
-
+
{ ? 'Enter the new name and directory of this file or folder, relative to the current directory.' : undefined } - autoFocus={true} + autoFocus />
- +
{useMoveTerminology && -

- New location: +

+ New location:  /home/container/{join(directory, values.name).replace(/^(\.\.\/|\/)+/, '')}

} diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx new file mode 100644 index 000000000..1723f9352 --- /dev/null +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } 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 { Textarea } from '@/components/elements/Input'; +import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes'; +import { debounce } from 'debounce'; +import InputSpinner from '@/components/elements/InputSpinner'; + +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 { uuid, allocations } = useServer(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [ loading, setLoading ] = useState(false); + const { data, error, mutate } = useSWR(uuid, key => getServerAllocations(key), { initialData: allocations }); + + const setPrimaryAllocation = (id: number) => { + clearFlashes('server:network'); + + const initial = data; + mutate(data?.map(a => a.id === id ? { ...a, isDefault: true } : { ...a, isDefault: false }), false); + + setPrimaryServerAllocation(uuid, id) + .catch(error => { + clearAndAddHttpError({ key: 'server:network', error }); + mutate(initial, false); + }); + }; + + const setAllocationNotes = debounce((id: number, notes: string) => { + setLoading(id); + clearFlashes('server:network'); + + setServerAllocationNotes(uuid, id, notes) + .then(() => mutate(data?.map(a => a.id === id ? { ...a, notes } : a), false)) + .catch(error => { + clearAndAddHttpError({ key: 'server:network', error }); + }) + .then(() => setLoading(false)); + }, 750); + + useEffect(() => { + if (error) { + clearAndAddHttpError({ key: 'server:network', error }); + } + }, [ error ]); + + return ( + + {!data ? + + : + data.map(({ id, ip, port, alias, notes, isDefault }, index) => ( + 0 ? tw`mt-2` : undefined} $hoverable={false}> +
+ +
+
+ {alias || ip} + +
+
+ {port} + +
+
+ +