From 5402584508083bc016618ca0dbd7310ff27a52b6 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Thu, 15 Dec 2022 19:06:14 -0700 Subject: [PATCH] ui(admin): add "working" React admin ui --- app/Http/Kernel.php | 2 + .../SubstituteApplicationApiBindings.php | 66 ++++ package.json | 4 +- .../api/admin/databases/createDatabase.ts | 12 + .../api/admin/databases/deleteDatabase.ts | 9 + .../api/admin/databases/getDatabase.ts | 10 + .../api/admin/databases/getDatabases.ts | 64 ++++ .../api/admin/databases/searchDatabases.ts | 25 ++ .../api/admin/databases/updateDatabase.ts | 12 + resources/scripts/api/admin/egg.ts | 104 ++++++ resources/scripts/api/admin/eggs/createEgg.ts | 31 ++ .../api/admin/eggs/createEggVariable.ts | 22 ++ resources/scripts/api/admin/eggs/deleteEgg.ts | 9 + .../api/admin/eggs/deleteEggVariable.ts | 9 + resources/scripts/api/admin/eggs/getEgg.ts | 108 ++++++ resources/scripts/api/admin/eggs/updateEgg.ts | 31 ++ .../api/admin/eggs/updateEggVariables.ts | 21 ++ resources/scripts/api/admin/getVersion.ts | 22 ++ resources/scripts/api/admin/index.ts | 66 ++++ resources/scripts/api/admin/location.ts | 13 + .../api/admin/locations/createLocation.ts | 12 + .../api/admin/locations/deleteLocation.ts | 9 + .../api/admin/locations/getLocation.ts | 10 + .../api/admin/locations/getLocations.ts | 54 +++ .../api/admin/locations/searchLocations.ts | 25 ++ .../api/admin/locations/updateLocation.ts | 12 + .../scripts/api/admin/mounts/createMount.ts | 12 + .../scripts/api/admin/mounts/deleteMount.ts | 9 + .../scripts/api/admin/mounts/getMount.ts | 10 + .../scripts/api/admin/mounts/getMounts.ts | 80 ++++ .../scripts/api/admin/mounts/updateMount.ts | 12 + resources/scripts/api/admin/nest.ts | 25 ++ .../scripts/api/admin/nests/createNest.ts | 12 + .../scripts/api/admin/nests/deleteNest.ts | 9 + resources/scripts/api/admin/nests/getEggs.ts | 38 ++ resources/scripts/api/admin/nests/getNest.ts | 10 + resources/scripts/api/admin/nests/getNests.ts | 66 ++++ .../scripts/api/admin/nests/importEgg.ts | 17 + .../scripts/api/admin/nests/updateNest.ts | 12 + resources/scripts/api/admin/node.ts | 84 +++++ .../nodes/allocations/createAllocation.ts | 16 + .../nodes/allocations/deleteAllocation.ts | 9 + .../admin/nodes/allocations/getAllocations.ts | 39 ++ .../scripts/api/admin/nodes/createNode.ts | 42 +++ .../scripts/api/admin/nodes/deleteNode.ts | 9 + .../scripts/api/admin/nodes/getAllocations.ts | 61 +++ resources/scripts/api/admin/nodes/getNode.ts | 10 + .../api/admin/nodes/getNodeConfiguration.ts | 9 + .../api/admin/nodes/getNodeInformation.ts | 19 + resources/scripts/api/admin/nodes/getNodes.ts | 107 ++++++ .../scripts/api/admin/nodes/updateNode.ts | 21 ++ resources/scripts/api/admin/roles.ts | 103 ++++++ resources/scripts/api/admin/server.ts | 99 +++++ .../scripts/api/admin/servers/createServer.ts | 80 ++++ .../scripts/api/admin/servers/deleteServer.ts | 9 + .../scripts/api/admin/servers/getServer.ts | 10 + .../scripts/api/admin/servers/getServers.ts | 177 +++++++++ .../scripts/api/admin/servers/updateServer.ts | 64 ++++ .../api/admin/servers/updateServerStartup.ts | 28 ++ resources/scripts/api/admin/users.ts | 96 +++++ .../scripts/api/definitions/admin/index.ts | 2 + .../scripts/api/definitions/admin/models.d.ts | 29 ++ .../api/definitions/admin/transformers.ts | 212 +++++++++++ resources/scripts/components/App.tsx | 15 +- .../scripts/components/admin/AdminBox.tsx | 36 ++ .../components/admin/AdminCheckbox.tsx | 36 ++ .../components/admin/AdminContentBlock.tsx | 42 +++ .../scripts/components/admin/AdminTable.tsx | 348 ++++++++++++++++++ .../scripts/components/admin/Sidebar.tsx | 87 +++++ .../components/admin/SubNavigation.tsx | 42 +++ .../admin/databases/DatabaseDeleteButton.tsx | 73 ++++ .../admin/databases/DatabaseEditContainer.tsx | 235 ++++++++++++ .../admin/databases/DatabasesContainer.tsx | 194 ++++++++++ .../admin/databases/NewDatabaseContainer.tsx | 48 +++ .../admin/locations/LocationDeleteButton.tsx | 74 ++++ .../admin/locations/LocationEditContainer.tsx | 180 +++++++++ .../admin/locations/LocationsContainer.tsx | 186 ++++++++++ .../admin/locations/NewLocationButton.tsx | 112 ++++++ .../admin/mounts/MountDeleteButton.tsx | 73 ++++ .../admin/mounts/MountEditContainer.tsx | 142 +++++++ .../components/admin/mounts/MountForm.tsx | 133 +++++++ .../admin/mounts/MountsContainer.tsx | 241 ++++++++++++ .../admin/mounts/NewMountContainer.tsx | 51 +++ .../admin/nests/ImportEggButton.tsx | 82 +++++ .../admin/nests/NestDeleteButton.tsx | 73 ++++ .../admin/nests/NestEditContainer.tsx | 250 +++++++++++++ .../components/admin/nests/NestEggTable.tsx | 160 ++++++++ .../components/admin/nests/NestsContainer.tsx | 182 +++++++++ .../admin/nests/NewEggContainer.tsx | 115 ++++++ .../components/admin/nests/NewNestButton.tsx | 112 ++++++ .../admin/nests/eggs/EggDeleteButton.tsx | 73 ++++ .../admin/nests/eggs/EggExportButton.tsx | 85 +++++ .../admin/nests/eggs/EggInstallContainer.tsx | 110 ++++++ .../components/admin/nests/eggs/EggRouter.tsx | 90 +++++ .../admin/nests/eggs/EggSettingsContainer.tsx | 245 ++++++++++++ .../nests/eggs/EggVariablesContainer.tsx | 218 +++++++++++ .../admin/nests/eggs/NewVariableButton.tsx | 103 ++++++ .../components/admin/nodes/DatabaseSelect.tsx | 56 +++ .../components/admin/nodes/LocationSelect.tsx | 56 +++ .../admin/nodes/NewNodeContainer.tsx | 127 +++++++ .../admin/nodes/NodeAboutContainer.tsx | 96 +++++ .../admin/nodes/NodeAllocationContainer.tsx | 27 ++ .../nodes/NodeConfigurationContainer.tsx | 70 ++++ .../admin/nodes/NodeDeleteButton.tsx | 73 ++++ .../admin/nodes/NodeEditContainer.tsx | 134 +++++++ .../admin/nodes/NodeLimitContainer.tsx | 47 +++ .../admin/nodes/NodeListenContainer.tsx | 37 ++ .../components/admin/nodes/NodeRouter.tsx | 146 ++++++++ .../components/admin/nodes/NodeServers.tsx | 10 + .../admin/nodes/NodeSettingsContainer.tsx | 95 +++++ .../components/admin/nodes/NodesContainer.tsx | 271 ++++++++++++++ .../nodes/allocations/AllocationTable.tsx | 216 +++++++++++ .../allocations/CreateAllocationForm.tsx | 118 ++++++ .../allocations/DeleteAllocationButton.tsx | 77 ++++ .../admin/overview/OverviewContainer.tsx | 103 ++++++ .../components/admin/roles/NewRoleButton.tsx | 107 ++++++ .../admin/roles/RoleDeleteButton.tsx | 73 ++++ .../admin/roles/RoleEditContainer.tsx | 176 +++++++++ .../components/admin/roles/RolesContainer.tsx | 182 +++++++++ .../components/admin/servers/EggSelect.tsx | 75 ++++ .../components/admin/servers/NestSelector.tsx | 44 +++ .../admin/servers/NewServerContainer.tsx | 225 +++++++++++ .../components/admin/servers/NodeSelect.tsx | 46 +++ .../components/admin/servers/OwnerSelect.tsx | 47 +++ .../admin/servers/ServerDeleteButton.tsx | 66 ++++ .../admin/servers/ServerManageContainer.tsx | 60 +++ .../components/admin/servers/ServerRouter.tsx | 76 ++++ .../admin/servers/ServerSettingsContainer.tsx | 103 ++++++ .../admin/servers/ServerStartupContainer.tsx | 258 +++++++++++++ .../admin/servers/ServersContainer.tsx | 36 ++ .../components/admin/servers/ServersTable.tsx | 236 ++++++++++++ .../servers/settings/BaseSettingsBox.tsx | 31 ++ .../servers/settings/FeatureLimitsBox.tsx | 38 ++ .../admin/servers/settings/NetworkingBox.tsx | 68 ++++ .../servers/settings/ServerResourceBox.tsx | 73 ++++ .../admin/settings/GeneralSettings.tsx | 37 ++ .../admin/settings/MailSettings.tsx | 102 +++++ .../admin/settings/SettingsContainer.tsx | 52 +++ .../admin/users/NewUserContainer.tsx | 49 +++ .../components/admin/users/RoleSelect.tsx | 56 +++ .../admin/users/UserAboutContainer.tsx | 62 ++++ .../admin/users/UserDeleteButton.tsx | 73 ++++ .../components/admin/users/UserForm.tsx | 148 ++++++++ .../components/admin/users/UserRouter.tsx | 114 ++++++ .../components/admin/users/UserServers.tsx | 10 + .../components/admin/users/UserTableRow.tsx | 79 ++++ .../components/admin/users/UsersContainer.tsx | 119 ++++++ .../activity/ActivityLogContainer.tsx | 2 +- .../dashboard/forms/RecoveryTokensDialog.tsx | 6 +- .../dashboard/forms/SetupTOTPDialog.tsx | 4 +- .../scripts/components/elements/Code.tsx | 2 +- .../components/elements/CopyOnClick.tsx | 2 +- .../scripts/components/elements/Editor.tsx | 318 ++++++++++++++++ .../scripts/components/elements/Field.tsx | 51 ++- .../components/elements/SearchableSelect.tsx | 315 ++++++++++++++++ .../components/elements/SelectField.tsx | 347 +++++++++++++++++ .../elements/activity/ActivityLogEntry.tsx | 12 +- .../activity/ActivityLogMetaButton.tsx | 4 +- .../elements/activity/style.module.css | 6 +- .../components/elements/alert/Alert.tsx | 2 +- .../elements/button/style.module.css | 14 +- .../components/elements/dialog/Dialog.tsx | 2 +- .../elements/dialog/DialogFooter.tsx | 4 +- .../elements/dialog/style.module.css | 6 +- .../elements/table/TFootPaginated.tsx | 23 ++ .../components/elements/tooltip/Tooltip.tsx | 4 +- resources/scripts/components/helpers.ts | 12 + .../server/ServerActivityLogContainer.tsx | 4 +- .../server/backups/BackupContextMenu.tsx | 6 +- .../components/server/console/ChartBlock.tsx | 2 +- .../components/server/console/Console.tsx | 2 +- .../server/console/ServerConsoleContainer.tsx | 2 +- .../server/console/ServerDetailsBlock.tsx | 10 +- .../components/server/console/StatBlock.tsx | 14 +- .../components/server/console/chart.ts | 4 +- .../server/console/style.module.css | 6 +- .../server/files/FileDropdownMenu.tsx | 2 +- .../server/files/FileManagerStatus.tsx | 4 +- .../server/files/MassActionsBar.tsx | 2 +- .../server/network/AllocationRow.tsx | 2 +- .../helpers/extractSearchFilters.spec.ts | 80 ++++ .../scripts/helpers/extractSearchFilters.ts | 49 +++ .../helpers/splitStringWhitespace.spec.ts | 16 + .../scripts/helpers/splitStringWhitespace.ts | 27 ++ .../scripts/plugins/useDebouncedState.ts | 12 + resources/scripts/routers/AdminRouter.tsx | 180 +++++++++ resources/scripts/state/admin/allocations.ts | 29 ++ resources/scripts/state/admin/databases.ts | 29 ++ resources/scripts/state/admin/index.ts | 44 +++ resources/scripts/state/admin/locations.ts | 29 ++ resources/scripts/state/admin/mounts.ts | 29 ++ resources/scripts/state/admin/nests.ts | 29 ++ resources/scripts/state/admin/nodes.ts | 29 ++ resources/scripts/state/admin/roles.ts | 29 ++ resources/scripts/state/admin/servers.ts | 29 ++ resources/scripts/state/admin/users.ts | 29 ++ routes/api-application.php | 100 ++--- tailwind.config.js | 17 +- yarn.lock | 221 ++++++++++- 199 files changed, 13387 insertions(+), 151 deletions(-) create mode 100644 app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php create mode 100644 resources/scripts/api/admin/databases/createDatabase.ts create mode 100644 resources/scripts/api/admin/databases/deleteDatabase.ts create mode 100644 resources/scripts/api/admin/databases/getDatabase.ts create mode 100644 resources/scripts/api/admin/databases/getDatabases.ts create mode 100644 resources/scripts/api/admin/databases/searchDatabases.ts create mode 100644 resources/scripts/api/admin/databases/updateDatabase.ts create mode 100644 resources/scripts/api/admin/egg.ts create mode 100644 resources/scripts/api/admin/eggs/createEgg.ts create mode 100644 resources/scripts/api/admin/eggs/createEggVariable.ts create mode 100644 resources/scripts/api/admin/eggs/deleteEgg.ts create mode 100644 resources/scripts/api/admin/eggs/deleteEggVariable.ts create mode 100644 resources/scripts/api/admin/eggs/getEgg.ts create mode 100644 resources/scripts/api/admin/eggs/updateEgg.ts create mode 100644 resources/scripts/api/admin/eggs/updateEggVariables.ts create mode 100644 resources/scripts/api/admin/getVersion.ts create mode 100644 resources/scripts/api/admin/index.ts create mode 100644 resources/scripts/api/admin/location.ts create mode 100644 resources/scripts/api/admin/locations/createLocation.ts create mode 100644 resources/scripts/api/admin/locations/deleteLocation.ts create mode 100644 resources/scripts/api/admin/locations/getLocation.ts create mode 100644 resources/scripts/api/admin/locations/getLocations.ts create mode 100644 resources/scripts/api/admin/locations/searchLocations.ts create mode 100644 resources/scripts/api/admin/locations/updateLocation.ts create mode 100644 resources/scripts/api/admin/mounts/createMount.ts create mode 100644 resources/scripts/api/admin/mounts/deleteMount.ts create mode 100644 resources/scripts/api/admin/mounts/getMount.ts create mode 100644 resources/scripts/api/admin/mounts/getMounts.ts create mode 100644 resources/scripts/api/admin/mounts/updateMount.ts create mode 100644 resources/scripts/api/admin/nest.ts create mode 100644 resources/scripts/api/admin/nests/createNest.ts create mode 100644 resources/scripts/api/admin/nests/deleteNest.ts create mode 100644 resources/scripts/api/admin/nests/getEggs.ts create mode 100644 resources/scripts/api/admin/nests/getNest.ts create mode 100644 resources/scripts/api/admin/nests/getNests.ts create mode 100644 resources/scripts/api/admin/nests/importEgg.ts create mode 100644 resources/scripts/api/admin/nests/updateNest.ts create mode 100644 resources/scripts/api/admin/node.ts create mode 100644 resources/scripts/api/admin/nodes/allocations/createAllocation.ts create mode 100644 resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts create mode 100644 resources/scripts/api/admin/nodes/allocations/getAllocations.ts create mode 100644 resources/scripts/api/admin/nodes/createNode.ts create mode 100644 resources/scripts/api/admin/nodes/deleteNode.ts create mode 100644 resources/scripts/api/admin/nodes/getAllocations.ts create mode 100644 resources/scripts/api/admin/nodes/getNode.ts create mode 100644 resources/scripts/api/admin/nodes/getNodeConfiguration.ts create mode 100644 resources/scripts/api/admin/nodes/getNodeInformation.ts create mode 100644 resources/scripts/api/admin/nodes/getNodes.ts create mode 100644 resources/scripts/api/admin/nodes/updateNode.ts create mode 100644 resources/scripts/api/admin/roles.ts create mode 100644 resources/scripts/api/admin/server.ts create mode 100644 resources/scripts/api/admin/servers/createServer.ts create mode 100644 resources/scripts/api/admin/servers/deleteServer.ts create mode 100644 resources/scripts/api/admin/servers/getServer.ts create mode 100644 resources/scripts/api/admin/servers/getServers.ts create mode 100644 resources/scripts/api/admin/servers/updateServer.ts create mode 100644 resources/scripts/api/admin/servers/updateServerStartup.ts create mode 100644 resources/scripts/api/admin/users.ts create mode 100644 resources/scripts/api/definitions/admin/index.ts create mode 100644 resources/scripts/api/definitions/admin/models.d.ts create mode 100644 resources/scripts/api/definitions/admin/transformers.ts create mode 100644 resources/scripts/components/admin/AdminBox.tsx create mode 100644 resources/scripts/components/admin/AdminCheckbox.tsx create mode 100644 resources/scripts/components/admin/AdminContentBlock.tsx create mode 100644 resources/scripts/components/admin/AdminTable.tsx create mode 100644 resources/scripts/components/admin/Sidebar.tsx create mode 100644 resources/scripts/components/admin/SubNavigation.tsx create mode 100644 resources/scripts/components/admin/databases/DatabaseDeleteButton.tsx create mode 100644 resources/scripts/components/admin/databases/DatabaseEditContainer.tsx create mode 100644 resources/scripts/components/admin/databases/DatabasesContainer.tsx create mode 100644 resources/scripts/components/admin/databases/NewDatabaseContainer.tsx create mode 100644 resources/scripts/components/admin/locations/LocationDeleteButton.tsx create mode 100644 resources/scripts/components/admin/locations/LocationEditContainer.tsx create mode 100644 resources/scripts/components/admin/locations/LocationsContainer.tsx create mode 100644 resources/scripts/components/admin/locations/NewLocationButton.tsx create mode 100644 resources/scripts/components/admin/mounts/MountDeleteButton.tsx create mode 100644 resources/scripts/components/admin/mounts/MountEditContainer.tsx create mode 100644 resources/scripts/components/admin/mounts/MountForm.tsx create mode 100644 resources/scripts/components/admin/mounts/MountsContainer.tsx create mode 100644 resources/scripts/components/admin/mounts/NewMountContainer.tsx create mode 100644 resources/scripts/components/admin/nests/ImportEggButton.tsx create mode 100644 resources/scripts/components/admin/nests/NestDeleteButton.tsx create mode 100644 resources/scripts/components/admin/nests/NestEditContainer.tsx create mode 100644 resources/scripts/components/admin/nests/NestEggTable.tsx create mode 100644 resources/scripts/components/admin/nests/NestsContainer.tsx create mode 100644 resources/scripts/components/admin/nests/NewEggContainer.tsx create mode 100644 resources/scripts/components/admin/nests/NewNestButton.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggDeleteButton.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggExportButton.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggRouter.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx create mode 100644 resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx create mode 100644 resources/scripts/components/admin/nodes/DatabaseSelect.tsx create mode 100644 resources/scripts/components/admin/nodes/LocationSelect.tsx create mode 100644 resources/scripts/components/admin/nodes/NewNodeContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeAboutContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeConfigurationContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeDeleteButton.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeEditContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeLimitContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeListenContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeRouter.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeServers.tsx create mode 100644 resources/scripts/components/admin/nodes/NodeSettingsContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/NodesContainer.tsx create mode 100644 resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx create mode 100644 resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx create mode 100644 resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx create mode 100644 resources/scripts/components/admin/overview/OverviewContainer.tsx create mode 100644 resources/scripts/components/admin/roles/NewRoleButton.tsx create mode 100644 resources/scripts/components/admin/roles/RoleDeleteButton.tsx create mode 100644 resources/scripts/components/admin/roles/RoleEditContainer.tsx create mode 100644 resources/scripts/components/admin/roles/RolesContainer.tsx create mode 100644 resources/scripts/components/admin/servers/EggSelect.tsx create mode 100644 resources/scripts/components/admin/servers/NestSelector.tsx create mode 100644 resources/scripts/components/admin/servers/NewServerContainer.tsx create mode 100644 resources/scripts/components/admin/servers/NodeSelect.tsx create mode 100644 resources/scripts/components/admin/servers/OwnerSelect.tsx create mode 100644 resources/scripts/components/admin/servers/ServerDeleteButton.tsx create mode 100644 resources/scripts/components/admin/servers/ServerManageContainer.tsx create mode 100644 resources/scripts/components/admin/servers/ServerRouter.tsx create mode 100644 resources/scripts/components/admin/servers/ServerSettingsContainer.tsx create mode 100644 resources/scripts/components/admin/servers/ServerStartupContainer.tsx create mode 100644 resources/scripts/components/admin/servers/ServersContainer.tsx create mode 100644 resources/scripts/components/admin/servers/ServersTable.tsx create mode 100644 resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx create mode 100644 resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx create mode 100644 resources/scripts/components/admin/servers/settings/NetworkingBox.tsx create mode 100644 resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx create mode 100644 resources/scripts/components/admin/settings/GeneralSettings.tsx create mode 100644 resources/scripts/components/admin/settings/MailSettings.tsx create mode 100644 resources/scripts/components/admin/settings/SettingsContainer.tsx create mode 100644 resources/scripts/components/admin/users/NewUserContainer.tsx create mode 100644 resources/scripts/components/admin/users/RoleSelect.tsx create mode 100644 resources/scripts/components/admin/users/UserAboutContainer.tsx create mode 100644 resources/scripts/components/admin/users/UserDeleteButton.tsx create mode 100644 resources/scripts/components/admin/users/UserForm.tsx create mode 100644 resources/scripts/components/admin/users/UserRouter.tsx create mode 100644 resources/scripts/components/admin/users/UserServers.tsx create mode 100644 resources/scripts/components/admin/users/UserTableRow.tsx create mode 100644 resources/scripts/components/admin/users/UsersContainer.tsx create mode 100644 resources/scripts/components/elements/Editor.tsx create mode 100644 resources/scripts/components/elements/SearchableSelect.tsx create mode 100644 resources/scripts/components/elements/SelectField.tsx create mode 100644 resources/scripts/components/elements/table/TFootPaginated.tsx create mode 100644 resources/scripts/components/helpers.ts create mode 100644 resources/scripts/helpers/extractSearchFilters.spec.ts create mode 100644 resources/scripts/helpers/extractSearchFilters.ts create mode 100644 resources/scripts/helpers/splitStringWhitespace.spec.ts create mode 100644 resources/scripts/helpers/splitStringWhitespace.ts create mode 100644 resources/scripts/plugins/useDebouncedState.ts create mode 100644 resources/scripts/routers/AdminRouter.tsx create mode 100644 resources/scripts/state/admin/allocations.ts create mode 100644 resources/scripts/state/admin/databases.ts create mode 100644 resources/scripts/state/admin/index.ts create mode 100644 resources/scripts/state/admin/locations.ts create mode 100644 resources/scripts/state/admin/mounts.ts create mode 100644 resources/scripts/state/admin/nests.ts create mode 100644 resources/scripts/state/admin/nodes.ts create mode 100644 resources/scripts/state/admin/roles.ts create mode 100644 resources/scripts/state/admin/servers.ts create mode 100644 resources/scripts/state/admin/users.ts diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 7df1ed2b1..ad0318720 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -33,6 +33,7 @@ use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientBindings; use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser; +use Pterodactyl\Http\Middleware\Api\Application\SubstituteApplicationApiBindings; class Kernel extends HttpKernel { @@ -70,6 +71,7 @@ class Kernel extends HttpKernel AuthenticateIPAccess::class, ], 'application-api' => [ +// SubstituteApplicationApiBindings::class, SubstituteBindings::class, AuthenticateApplicationUser::class, ], diff --git a/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php b/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php new file mode 100644 index 000000000..e629f6ca6 --- /dev/null +++ b/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php @@ -0,0 +1,66 @@ + Allocation::class, + 'database' => Database::class, + 'egg' => Egg::class, + 'location' => Location::class, + 'nest' => Nest::class, + 'node' => Node::class, + 'server' => Server::class, + 'user' => User::class, + ]; + + public function __construct(Registrar $router) + { + $this->router = $router; + } + + /** + * Perform substitution of route parameters without triggering + * a 404 error if a model is not found. + * + * @param \Illuminate\Http\Request $request + * + * @return mixed + */ + public function handle($request, Closure $next) + { + foreach (self::$mappings as $key => $class) { + $this->router->bind($key, $class); + } + + try { + $this->router->substituteImplicitBindings($route = $request->route()); + } catch (ModelNotFoundException $exception) { + if (!empty($route) && $route->getMissing()) { + $route->getMissing()($request); + } + + throw $exception; + } + + return $next($request); + } +} diff --git a/package.json b/package.json index 6641cd841..00b8764a1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@codemirror/view": "^6.0.0", "@floating-ui/react-dom-interactions": "0.13.3", "@fortawesome/fontawesome-svg-core": "6.2.1", + "@fortawesome/free-brands-svg-icons": "6.2.1", "@fortawesome/free-solid-svg-icons": "6.2.1", "@fortawesome/react-fontawesome": "0.2.0", "@flyyer/use-fit-text": "3.0.1", @@ -72,6 +73,7 @@ "react-fast-compare": "3.2.0", "react-i18next": "12.1.1", "react-router-dom": "6.4.5", + "react-select": "5.7.0", "reaptcha": "1.12.1", "sockette": "2.0.6", "styled-components": "5.3.6", @@ -109,7 +111,7 @@ "eslint-plugin-react": "7.31.11", "eslint-plugin-react-hooks": "4.6.0", "happy-dom": "8.1.0", - "laravel-vite-plugin": "0.7.1", + "laravel-vite-plugin": "0.7.2", "pathe": "1.0.0", "postcss": "8.4.20", "postcss-nesting": "10.2.0", diff --git a/resources/scripts/api/admin/databases/createDatabase.ts b/resources/scripts/api/admin/databases/createDatabase.ts new file mode 100644 index 000000000..98d37bf1d --- /dev/null +++ b/resources/scripts/api/admin/databases/createDatabase.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases'; + +export default (name: string, host: string, port: number, username: string, password: string, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/databases', { + name, host, port, username, password, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToDatabase(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/databases/deleteDatabase.ts b/resources/scripts/api/admin/databases/deleteDatabase.ts new file mode 100644 index 000000000..436aeaa85 --- /dev/null +++ b/resources/scripts/api/admin/databases/deleteDatabase.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/databases/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/databases/getDatabase.ts b/resources/scripts/api/admin/databases/getDatabase.ts new file mode 100644 index 000000000..0af69fcd6 --- /dev/null +++ b/resources/scripts/api/admin/databases/getDatabase.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases'; + +export default (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/databases/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToDatabase(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/databases/getDatabases.ts b/resources/scripts/api/admin/databases/getDatabases.ts new file mode 100644 index 000000000..4ad5f4a3d --- /dev/null +++ b/resources/scripts/api/admin/databases/getDatabases.ts @@ -0,0 +1,64 @@ +import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; + +export interface Database { + id: number; + name: string; + host: string; + port: number; + username: string; + maxDatabases: number; + createdAt: Date; + updatedAt: Date; + + getAddress (): string; +} + +export const rawDataToDatabase = ({ attributes }: FractalResponseData): Database => ({ + id: attributes.id, + name: attributes.name, + host: attributes.host, + port: attributes.port, + username: attributes.username, + maxDatabases: attributes.max_databases, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + getAddress: () => `${attributes.host}:${attributes.port}`, +}); + +export interface Filters { + id?: string; + name?: string; + host?: string; +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'databases', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/databases', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToDatabase), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/databases/searchDatabases.ts b/resources/scripts/api/admin/databases/searchDatabases.ts new file mode 100644 index 000000000..99533b5dc --- /dev/null +++ b/resources/scripts/api/admin/databases/searchDatabases.ts @@ -0,0 +1,25 @@ +import http from '@/api/http'; +import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases'; + +interface Filters { + name?: string; + host?: string; +} + +export default (filters?: Filters): Promise => { + const params = {}; + if (filters !== undefined) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + return new Promise((resolve, reject) => { + http.get('/api/application/databases', { params }) + .then(response => resolve( + (response.data.data || []).map(rawDataToDatabase) + )) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/databases/updateDatabase.ts b/resources/scripts/api/admin/databases/updateDatabase.ts new file mode 100644 index 000000000..7d01a9024 --- /dev/null +++ b/resources/scripts/api/admin/databases/updateDatabase.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases'; + +export default (id: number, name: string, host: string, port: number, username: string, password: string | undefined, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch(`/api/application/databases/${id}`, { + name, host, port, username, password, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToDatabase(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/egg.ts b/resources/scripts/api/admin/egg.ts new file mode 100644 index 000000000..874fd7ad7 --- /dev/null +++ b/resources/scripts/api/admin/egg.ts @@ -0,0 +1,104 @@ +import type { AxiosError } from 'axios'; +import { useParams } from 'react-router-dom'; +import type { SWRResponse } from 'swr'; +import useSWR from 'swr'; + +import type { Model, UUID, WithRelationships } from '@/api/admin/index'; +import { withRelationships } from '@/api/admin/index'; +import type { Nest } from '@/api/admin/nest'; +import type { QueryBuilderParams } from '@/api/http'; +import http, { withQueryBuilderParams } from '@/api/http'; +import { Transformers } from '@definitions/admin'; + +export interface Egg extends Model { + id: number; + uuid: UUID; + nestId: number; + author: string; + name: string; + description: string | null; + features: string[] | null; + dockerImages: Record; + configFiles: Record | null; + configStartup: Record | null; + configStop: string | null; + configFrom: number | null; + startup: string; + scriptContainer: string; + copyScriptFrom: number | null; + scriptEntry: string; + scriptIsPrivileged: boolean; + scriptInstall: string | null; + createdAt: Date; + updatedAt: Date; + relationships: { + nest?: Nest; + variables?: EggVariable[]; + }; +} + +export interface EggVariable extends Model { + id: number; + eggId: number; + name: string; + description: string; + environmentVariable: string; + defaultValue: string; + isUserViewable: boolean; + isUserEditable: boolean; + // isRequired: boolean; + rules: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * A standard API response with the minimum viable details for the frontend + * to correctly render a egg. + */ +type LoadedEgg = WithRelationships; + +/** + * Gets a single egg from the database and returns it. + */ +export const getEgg = async (id: number | string): Promise => { + const { data } = await http.get(`/api/application/eggs/${id}`, { + params: { + include: ['nest', 'variables'], + }, + }); + + return withRelationships(Transformers.toEgg(data), 'nest', 'variables'); +}; + +export const searchEggs = async ( + nestId: number, + params: QueryBuilderParams<'name'>, +): Promise[]> => { + const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { + params: { + ...withQueryBuilderParams(params), + include: ['variables'], + }, + }); + + return data.data.map(Transformers.toEgg); +}; + +export const exportEgg = async (eggId: number): Promise> => { + const { data } = await http.get(`/api/application/eggs/${eggId}/export`); + return data; +}; + +/** + * Returns an SWR instance by automatically loading in the server for the currently + * loaded route match in the admin area. + */ +export const useEggFromRoute = (): SWRResponse => { + const params = useParams<'id'>(); + + return useSWR(`/api/application/eggs/${params.id}`, async () => getEgg(Number(params.id)), { + revalidateOnMount: false, + revalidateOnFocus: false, + }); +}; diff --git a/resources/scripts/api/admin/eggs/createEgg.ts b/resources/scripts/api/admin/eggs/createEgg.ts new file mode 100644 index 000000000..0ad08d9ee --- /dev/null +++ b/resources/scripts/api/admin/eggs/createEgg.ts @@ -0,0 +1,31 @@ +import http from '@/api/http'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; + +type Egg2 = Omit, 'configFiles'>, 'configStartup'> & { configFiles: string, configStartup: string }; + +export default (egg: Partial): Promise => { + return new Promise((resolve, reject) => { + http.post( + '/api/application/eggs', + { + nest_id: egg.nestId, + name: egg.name, + description: egg.description, + features: egg.features, + docker_images: egg.dockerImages, + config_files: egg.configFiles, + config_startup: egg.configStartup, + config_stop: egg.configStop, + config_from: egg.configFrom, + startup: egg.startup, + script_container: egg.scriptContainer, + copy_script_from: egg.copyScriptFrom, + script_entry: egg.scriptEntry, + script_is_privileged: egg.scriptIsPrivileged, + script_install: egg.scriptInstall, + }, + ) + .then(({ data }) => resolve(rawDataToEgg(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/eggs/createEggVariable.ts b/resources/scripts/api/admin/eggs/createEggVariable.ts new file mode 100644 index 000000000..375283d2c --- /dev/null +++ b/resources/scripts/api/admin/eggs/createEggVariable.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; +import { EggVariable } from '@/api/admin/egg'; +import { Transformers } from '@definitions/admin'; + +export type CreateEggVariable = Omit; + +export default async (eggId: number, variable: CreateEggVariable): Promise => { + const { data } = await http.post( + `/api/application/eggs/${eggId}/variables`, + { + name: variable.name, + description: variable.description, + env_variable: variable.environmentVariable, + default_value: variable.defaultValue, + user_viewable: variable.isUserViewable, + user_editable: variable.isUserEditable, + rules: variable.rules, + }, + ); + + return Transformers.toEggVariable(data); +}; diff --git a/resources/scripts/api/admin/eggs/deleteEgg.ts b/resources/scripts/api/admin/eggs/deleteEgg.ts new file mode 100644 index 000000000..635f3d6c2 --- /dev/null +++ b/resources/scripts/api/admin/eggs/deleteEgg.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/eggs/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/eggs/deleteEggVariable.ts b/resources/scripts/api/admin/eggs/deleteEggVariable.ts new file mode 100644 index 000000000..967798f55 --- /dev/null +++ b/resources/scripts/api/admin/eggs/deleteEggVariable.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (eggId: number, variableId: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/eggs/${eggId}/variables/${variableId}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/eggs/getEgg.ts b/resources/scripts/api/admin/eggs/getEgg.ts new file mode 100644 index 000000000..2a6bb0633 --- /dev/null +++ b/resources/scripts/api/admin/eggs/getEgg.ts @@ -0,0 +1,108 @@ +import { Nest } from '@/api/admin/nests/getNests'; +import { rawDataToServer, Server } from '@/api/admin/servers/getServers'; +import http, { FractalResponseData, FractalResponseList } from '@/api/http'; +import useSWR from 'swr'; + +export interface EggVariable { + id: number; + eggId: number; + name: string; + description: string; + envVariable: string; + defaultValue: string; + userViewable: boolean; + userEditable: boolean; + rules: string; + createdAt: Date; + updatedAt: Date; +} + +export const rawDataToEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ + id: attributes.id, + eggId: attributes.egg_id, + name: attributes.name, + description: attributes.description, + envVariable: attributes.env_variable, + defaultValue: attributes.default_value, + userViewable: attributes.user_viewable, + userEditable: attributes.user_editable, + rules: attributes.rules, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), +}); + +export interface Egg { + id: number; + uuid: string; + nestId: number; + author: string; + name: string; + description: string | null; + features: string[] | null; + dockerImages: Record; + configFiles: Record | null; + configStartup: Record | null; + configStop: string | null; + configFrom: number | null; + startup: string; + scriptContainer: string; + copyScriptFrom: number | null; + scriptEntry: string; + scriptIsPrivileged: boolean; + scriptInstall: string | null; + createdAt: Date; + updatedAt: Date; + + relations: { + nest?: Nest; + servers?: Server[]; + variables?: EggVariable[]; + }; +} + +export const rawDataToEgg = ({ attributes }: FractalResponseData): Egg => ({ + id: attributes.id, + uuid: attributes.uuid, + nestId: attributes.nest_id, + author: attributes.author, + name: attributes.name, + description: attributes.description, + features: attributes.features, + dockerImages: attributes.docker_images, + configFiles: attributes.config?.files, + configStartup: attributes.config?.startup, + configStop: attributes.config?.stop, + configFrom: attributes.config?.extends, + startup: attributes.startup, + copyScriptFrom: attributes.copy_script_from, + scriptContainer: attributes.script?.container, + scriptEntry: attributes.script?.entry, + scriptIsPrivileged: attributes.script?.privileged, + scriptInstall: attributes.script?.install, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + relations: { + nest: undefined, + servers: ((attributes.relationships?.servers as FractalResponseList | undefined)?.data || []).map( + rawDataToServer, + ), + variables: ((attributes.relationships?.variables as FractalResponseList | undefined)?.data || []).map( + rawDataToEggVariable, + ), + }, +}); + +export const getEgg = async (id: number): Promise => { + const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: ['variables'] } }); + + return rawDataToEgg(data); +}; + +export default (id: number) => { + return useSWR(`egg:${id}`, async () => { + const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: ['variables'] } }); + + return rawDataToEgg(data); + }); +}; diff --git a/resources/scripts/api/admin/eggs/updateEgg.ts b/resources/scripts/api/admin/eggs/updateEgg.ts new file mode 100644 index 000000000..2500ba6b2 --- /dev/null +++ b/resources/scripts/api/admin/eggs/updateEgg.ts @@ -0,0 +1,31 @@ +import http from '@/api/http'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; + +type Egg2 = Omit, 'configFiles'>, 'configStartup'> & { configFiles?: string, configStartup?: string }; + +export default (id: number, egg: Partial): Promise => { + return new Promise((resolve, reject) => { + http.patch( + `/api/application/eggs/${id}`, + { + nest_id: egg.nestId, + name: egg.name, + description: egg.description, + features: egg.features, + docker_images: egg.dockerImages, + config_files: egg.configFiles, + config_startup: egg.configStartup, + config_stop: egg.configStop, + config_from: egg.configFrom, + startup: egg.startup, + script_container: egg.scriptContainer, + copy_script_from: egg.copyScriptFrom, + script_entry: egg.scriptEntry, + script_is_privileged: egg.scriptIsPrivileged, + script_install: egg.scriptInstall, + }, + ) + .then(({ data }) => resolve(rawDataToEgg(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/eggs/updateEggVariables.ts b/resources/scripts/api/admin/eggs/updateEggVariables.ts new file mode 100644 index 000000000..b5d97b952 --- /dev/null +++ b/resources/scripts/api/admin/eggs/updateEggVariables.ts @@ -0,0 +1,21 @@ +import http from '@/api/http'; +import { EggVariable } from '@/api/admin/egg'; +import { Transformers } from '@definitions/admin'; + +export default async (eggId: number, variables: Omit[]): Promise => { + const { data } = await http.patch( + `/api/application/eggs/${eggId}/variables`, + variables.map(variable => ({ + id: variable.id, + name: variable.name, + description: variable.description, + env_variable: variable.environmentVariable, + default_value: variable.defaultValue, + user_viewable: variable.isUserViewable, + user_editable: variable.isUserEditable, + rules: variable.rules, + })), + ); + + return data.data.map(Transformers.toEggVariable); +}; diff --git a/resources/scripts/api/admin/getVersion.ts b/resources/scripts/api/admin/getVersion.ts new file mode 100644 index 000000000..3f24911e4 --- /dev/null +++ b/resources/scripts/api/admin/getVersion.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; + +export interface VersionData { + panel: { + current: string; + latest: string; + } + + wings: { + latest: string; + } + + git: string | null; +} + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/application/version') + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/index.ts b/resources/scripts/api/admin/index.ts new file mode 100644 index 000000000..014a207a7 --- /dev/null +++ b/resources/scripts/api/admin/index.ts @@ -0,0 +1,66 @@ +import { createContext } from 'react'; + +export interface Model { + relationships: Record; +} + +export type UUID = string; + +/** + * Marks the provided relationships keys as present in the given model + * rather than being optional to improve typing responses. + */ +export type WithRelationships = Omit & { + relationships: Omit & { + [K in R]: NonNullable; + } +} + +/** + * Helper type that allows you to infer the type of an object by giving + * it the specific API request function with a return type. For example: + * + * type EggT = InferModel; + */ +export type InferModel any> = ReturnType extends Promise ? U : T; + +/** + * Helper function that just returns the model you pass in, but types the model + * such that TypeScript understands the relationships on it. This is just to help + * reduce the amount of duplicated type casting all over the codebase. + */ +export const withRelationships = (model: M, ..._keys: R[]) => { + return model as unknown as WithRelationships; +}; + +export interface ListContext { + page: number; + setPage: (page: ((p: number) => number) | number) => void; + + filters: T | null; + setFilters: (filters: ((f: T | null) => T | null) | T | null) => void; + + sort: string | null; + setSort: (sort: string | null) => void; + + sortDirection: boolean; + setSortDirection: (direction: ((p: boolean) => boolean) | boolean) => void; +} + +function create () { + return createContext>({ + page: 1, + setPage: () => 1, + + filters: null, + setFilters: () => null, + + sort: null, + setSort: () => null, + + sortDirection: false, + setSortDirection: () => false, + }); +} + +export { create as createContext }; diff --git a/resources/scripts/api/admin/location.ts b/resources/scripts/api/admin/location.ts new file mode 100644 index 000000000..82ff394f8 --- /dev/null +++ b/resources/scripts/api/admin/location.ts @@ -0,0 +1,13 @@ +import { Model } from '@/api/admin/index'; +import { Node } from '@/api/admin/node'; + +export interface Location extends Model { + id: number; + short: string; + long: string; + createdAt: Date; + updatedAt: Date; + relationships: { + nodes?: Node[]; + }; +} diff --git a/resources/scripts/api/admin/locations/createLocation.ts b/resources/scripts/api/admin/locations/createLocation.ts new file mode 100644 index 000000000..053148fc4 --- /dev/null +++ b/resources/scripts/api/admin/locations/createLocation.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations'; + +export default (short: string, long: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/locations', { + short, long, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToLocation(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/locations/deleteLocation.ts b/resources/scripts/api/admin/locations/deleteLocation.ts new file mode 100644 index 000000000..85b42d60e --- /dev/null +++ b/resources/scripts/api/admin/locations/deleteLocation.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/locations/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/locations/getLocation.ts b/resources/scripts/api/admin/locations/getLocation.ts new file mode 100644 index 000000000..9a1aad4bc --- /dev/null +++ b/resources/scripts/api/admin/locations/getLocation.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations'; + +export default (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/locations/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToLocation(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/locations/getLocations.ts b/resources/scripts/api/admin/locations/getLocations.ts new file mode 100644 index 000000000..f43567e51 --- /dev/null +++ b/resources/scripts/api/admin/locations/getLocations.ts @@ -0,0 +1,54 @@ +import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; + +export interface Location { + id: number; + short: string; + long: string; + createdAt: Date; + updatedAt: Date; +} + +export const rawDataToLocation = ({ attributes }: FractalResponseData): Location => ({ + id: attributes.id, + short: attributes.short, + long: attributes.long, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), +}); + +export interface Filters { + id?: string; + short?: string; + long?: string; +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'locations', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/locations', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToLocation), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/locations/searchLocations.ts b/resources/scripts/api/admin/locations/searchLocations.ts new file mode 100644 index 000000000..6edc50e62 --- /dev/null +++ b/resources/scripts/api/admin/locations/searchLocations.ts @@ -0,0 +1,25 @@ +import http from '@/api/http'; +import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations'; + +interface Filters { + short?: string; + long?: string; +} + +export default (filters?: Filters): Promise => { + const params = {}; + if (filters !== undefined) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + return new Promise((resolve, reject) => { + http.get('/api/application/locations', { params }) + .then(response => resolve( + (response.data.data || []).map(rawDataToLocation) + )) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/locations/updateLocation.ts b/resources/scripts/api/admin/locations/updateLocation.ts new file mode 100644 index 000000000..bbb5f8f4c --- /dev/null +++ b/resources/scripts/api/admin/locations/updateLocation.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations'; + +export default (id: number, short: string, long: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch(`/api/application/locations/${id}`, { + short, long, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToLocation(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/mounts/createMount.ts b/resources/scripts/api/admin/mounts/createMount.ts new file mode 100644 index 000000000..63538c821 --- /dev/null +++ b/resources/scripts/api/admin/mounts/createMount.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts'; + +export default (name: string, description: string, source: string, target: string, readOnly: boolean, userMountable: boolean, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/mounts', { + name, description, source, target, read_only: readOnly, user_mountable: userMountable, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToMount(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/mounts/deleteMount.ts b/resources/scripts/api/admin/mounts/deleteMount.ts new file mode 100644 index 000000000..e4ec1d113 --- /dev/null +++ b/resources/scripts/api/admin/mounts/deleteMount.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/mounts/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/mounts/getMount.ts b/resources/scripts/api/admin/mounts/getMount.ts new file mode 100644 index 000000000..9bd8a56e9 --- /dev/null +++ b/resources/scripts/api/admin/mounts/getMount.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts'; + +export default (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/mounts/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToMount(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/mounts/getMounts.ts b/resources/scripts/api/admin/mounts/getMounts.ts new file mode 100644 index 000000000..449308119 --- /dev/null +++ b/resources/scripts/api/admin/mounts/getMounts.ts @@ -0,0 +1,80 @@ +import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; +import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; + +export interface Mount { + id: number; + uuid: string; + name: string; + description?: string; + source: string; + target: string; + readOnly: boolean; + userMountable: boolean; + createdAt: Date; + updatedAt: Date; + + relations: { + eggs: Egg[] | undefined; + nodes: Node[] | undefined; + servers: Server[] | undefined; + }; +} + +export const rawDataToMount = ({ attributes }: FractalResponseData): Mount => ({ + id: attributes.id, + uuid: attributes.uuid, + name: attributes.name, + description: attributes.description, + source: attributes.source, + target: attributes.target, + readOnly: attributes.read_only, + userMountable: attributes.user_mountable, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + relations: { + eggs: ((attributes.relationships?.eggs as FractalResponseList | undefined)?.data || []).map(rawDataToEgg), + nodes: ((attributes.relationships?.nodes as FractalResponseList | undefined)?.data || []).map(rawDataToNode), + servers: ((attributes.relationships?.servers as FractalResponseList | undefined)?.data || []).map(rawDataToServer), + }, +}); + +export interface Filters { + id?: string; + name?: string; + source?: string; + target?: string; +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'mounts', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/mounts', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToMount), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/mounts/updateMount.ts b/resources/scripts/api/admin/mounts/updateMount.ts new file mode 100644 index 000000000..c2485777a --- /dev/null +++ b/resources/scripts/api/admin/mounts/updateMount.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Mount, rawDataToMount } from '@/api/admin/mounts/getMounts'; + +export default (id: number, name: string, description: string | null, source: string, target: string, readOnly: boolean, userMountable: boolean, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch(`/api/application/mounts/${id}`, { + name, description, source, target, read_only: readOnly, user_mountable: userMountable, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToMount(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nest.ts b/resources/scripts/api/admin/nest.ts new file mode 100644 index 000000000..697b15bed --- /dev/null +++ b/resources/scripts/api/admin/nest.ts @@ -0,0 +1,25 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Egg } from '@/api/admin/egg'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { Transformers } from '@definitions/admin'; + +export interface Nest extends Model { + id: number; + uuid: UUID; + author: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + relationships: { + eggs?: Egg[]; + }; +} + +export const searchNests = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nests', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(Transformers.toNest); +}; diff --git a/resources/scripts/api/admin/nests/createNest.ts b/resources/scripts/api/admin/nests/createNest.ts new file mode 100644 index 000000000..6f8d045fa --- /dev/null +++ b/resources/scripts/api/admin/nests/createNest.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; + +export default (name: string, description: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/nests', { + name, description, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNest(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nests/deleteNest.ts b/resources/scripts/api/admin/nests/deleteNest.ts new file mode 100644 index 000000000..d6d4ae3a5 --- /dev/null +++ b/resources/scripts/api/admin/nests/deleteNest.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/nests/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nests/getEggs.ts b/resources/scripts/api/admin/nests/getEggs.ts new file mode 100644 index 000000000..6a406dc96 --- /dev/null +++ b/resources/scripts/api/admin/nests/getEggs.ts @@ -0,0 +1,38 @@ +import http, { getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; + +export interface Filters { + id?: string; + name?: string; +} + +export const Context = createContext(); + +export default (nestId: number, include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ nestId, 'eggs', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToEgg), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/nests/getNest.ts b/resources/scripts/api/admin/nests/getNest.ts new file mode 100644 index 000000000..23f1cf782 --- /dev/null +++ b/resources/scripts/api/admin/nests/getNest.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; + +export default (id: number, include: string[]): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/nests/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNest(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nests/getNests.ts b/resources/scripts/api/admin/nests/getNests.ts new file mode 100644 index 000000000..712a2a29b --- /dev/null +++ b/resources/scripts/api/admin/nests/getNests.ts @@ -0,0 +1,66 @@ +import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; + +export interface Nest { + id: number; + uuid: string; + author: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + + relations: { + eggs: Egg[] | undefined; + }, +} + +export const rawDataToNest = ({ attributes }: FractalResponseData): Nest => ({ + id: attributes.id, + uuid: attributes.uuid, + author: attributes.author, + name: attributes.name, + description: attributes.description, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + relations: { + eggs: ((attributes.relationships?.eggs as FractalResponseList | undefined)?.data || []).map(rawDataToEgg), + }, +}); + +export interface Filters { + id?: string; + name?: string; +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'nests', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/nests', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToNest), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/nests/importEgg.ts b/resources/scripts/api/admin/nests/importEgg.ts new file mode 100644 index 000000000..2163386ca --- /dev/null +++ b/resources/scripts/api/admin/nests/importEgg.ts @@ -0,0 +1,17 @@ +import http from '@/api/http'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; + +export default (id: number, content: any, type = 'application/json', include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/application/nests/${id}/import`, content, { + headers: { + 'Content-Type': type, + }, + params: { + include: include.join(','), + }, + }) + .then(({ data }) => resolve(rawDataToEgg(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nests/updateNest.ts b/resources/scripts/api/admin/nests/updateNest.ts new file mode 100644 index 000000000..869f0532b --- /dev/null +++ b/resources/scripts/api/admin/nests/updateNest.ts @@ -0,0 +1,12 @@ +import http from '@/api/http'; +import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; + +export default (id: number, name: string, description: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch(`/api/application/nests/${id}`, { + name, description, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNest(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/node.ts b/resources/scripts/api/admin/node.ts new file mode 100644 index 000000000..dae7a09cc --- /dev/null +++ b/resources/scripts/api/admin/node.ts @@ -0,0 +1,84 @@ +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Location } from '@/api/admin/location'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { Transformers } from '@definitions/admin'; +import { Server } from '@/api/admin/server'; + +interface NodePorts { + http: { + listen: number; + public: number; + }; + sftp: { + listen: number; + public: number; + }; +} + +export interface Allocation extends Model { + id: number; + ip: string; + port: number; + alias: string | null; + isAssigned: boolean; + relationships: { + node?: Node; + server?: Server | null; + }; + getDisplayText(): string; +} + +export interface Node extends Model { + id: number; + uuid: UUID; + isPublic: boolean; + locationId: number; + databaseHostId: number; + name: string; + description: string | null; + fqdn: string; + ports: NodePorts; + scheme: 'http' | 'https'; + isBehindProxy: boolean; + isMaintenanceMode: boolean; + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + uploadSize: number; + daemonBase: string; + createdAt: Date; + updatedAt: Date; + relationships: { + location?: Location; + }; +} + +/** + * Gets a single node and returns it. + */ +export const getNode = async (id: string | number): Promise> => { + const { data } = await http.get(`/api/application/nodes/${id}`, { + params: { + include: [ 'location' ], + }, + }); + + return withRelationships(Transformers.toNode(data.data), 'location'); +}; + +export const searchNodes = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nodes', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(Transformers.toNode); +}; + +export const getAllocations = async (id: string | number, params?: QueryBuilderParams<'ip' | 'server_id'>): Promise => { + const { data } = await http.get(`/api/application/nodes/${id}/allocations`, { + params: withQueryBuilderParams(params), + }); + + return data.data.map(Transformers.toAllocation); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/createAllocation.ts b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts new file mode 100644 index 000000000..89bacbd4d --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/createAllocation.ts @@ -0,0 +1,16 @@ +import http from '@/api/http'; +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; + +export interface Values { + ip: string; + ports: number[]; + alias?: string; +} + +export default (id: string | number, values: Values, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/application/nodes/${id}/allocations`, values, { params: { include: include.join(',') } }) + .then(({ data }) => resolve((data || []).map(rawDataToAllocation))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts b/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts new file mode 100644 index 000000000..f4a775183 --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (nodeId: number, allocationId: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/nodes/${nodeId}/allocations/${allocationId}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts new file mode 100644 index 000000000..14c99c655 --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts @@ -0,0 +1,39 @@ +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; +import http, { getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; + +export interface Filters { + id?: string; + ip?: string; + port?: string; +} + +export const Context = createContext(); + +export default (id: number, include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'allocations', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToAllocation), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/nodes/createNode.ts b/resources/scripts/api/admin/nodes/createNode.ts new file mode 100644 index 000000000..8143a92c3 --- /dev/null +++ b/resources/scripts/api/admin/nodes/createNode.ts @@ -0,0 +1,42 @@ +import http from '@/api/http'; +import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; + +export interface Values { + name: string; + locationId: number; + databaseHostId: number | null; + fqdn: string; + scheme: string; + behindProxy: boolean; + public: boolean; + daemonBase: string; + + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + + listenPortHTTP: number; + publicPortHTTP: number; + listenPortSFTP: number; + publicPortSFTP: number; +} + +export default (values: Values, include: string[] = []): Promise => { + const data = {}; + + Object.keys(values).forEach((key) => { + const key2 = key + .replace('HTTP', 'Http') + .replace('SFTP', 'Sftp') + .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + // @ts-ignore + data[key2] = values[key]; + }); + + return new Promise((resolve, reject) => { + http.post('/api/application/nodes', data, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNode(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/deleteNode.ts b/resources/scripts/api/admin/nodes/deleteNode.ts new file mode 100644 index 000000000..56a426111 --- /dev/null +++ b/resources/scripts/api/admin/nodes/deleteNode.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/nodes/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/getAllocations.ts b/resources/scripts/api/admin/nodes/getAllocations.ts new file mode 100644 index 000000000..e6f773dfe --- /dev/null +++ b/resources/scripts/api/admin/nodes/getAllocations.ts @@ -0,0 +1,61 @@ +import http, { FractalResponseData } from '@/api/http'; +import { rawDataToServer, Server } from '@/api/admin/servers/getServers'; + +export interface Allocation { + id: number; + ip: string; + port: number; + alias: string | null; + serverId: number | null; + assigned: boolean; + + relations: { + server?: Server; + } + + getDisplayText (): string; +} + +export const rawDataToAllocation = ({ attributes }: FractalResponseData): Allocation => ({ + id: attributes.id, + ip: attributes.ip, + port: attributes.port, + alias: attributes.alias || null, + serverId: attributes.server_id, + assigned: attributes.assigned, + + relations: { + server: attributes.relationships?.server?.object === 'server' ? rawDataToServer(attributes.relationships.server as FractalResponseData) : undefined, + }, + + // TODO: If IP is an IPv6, wrap IP in []. + getDisplayText (): string { + if (attributes.alias !== null) { + return `${attributes.ip}:${attributes.port} (${attributes.alias})`; + } + return `${attributes.ip}:${attributes.port}`; + }, +}); + +export interface Filters { + ip?: string + /* eslint-disable camelcase */ + server_id?: string; + /* eslint-enable camelcase */ +} + +export default (id: string | number, filters: Filters = {}, include: string[] = []): Promise => { + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + return new Promise((resolve, reject) => { + http.get(`/api/application/nodes/${id}/allocations`, { params: { include: include.join(','), ...params } }) + .then(({ data }) => resolve((data.data || []).map(rawDataToAllocation))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/getNode.ts b/resources/scripts/api/admin/nodes/getNode.ts new file mode 100644 index 000000000..b940c8e2e --- /dev/null +++ b/resources/scripts/api/admin/nodes/getNode.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; + +export default (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/nodes/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNode(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/getNodeConfiguration.ts b/resources/scripts/api/admin/nodes/getNodeConfiguration.ts new file mode 100644 index 000000000..f439b1e7e --- /dev/null +++ b/resources/scripts/api/admin/nodes/getNodeConfiguration.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/nodes/${id}/configuration?format=yaml`) + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/getNodeInformation.ts b/resources/scripts/api/admin/nodes/getNodeInformation.ts new file mode 100644 index 000000000..771433a5e --- /dev/null +++ b/resources/scripts/api/admin/nodes/getNodeInformation.ts @@ -0,0 +1,19 @@ +import http from '@/api/http'; + +export interface NodeInformation { + version: string; + system: { + type: string; + arch: string; + release: string; + cpus: number; + }; +} + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/nodes/${id}/information`) + .then(({ data }) => resolve(data)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/getNodes.ts b/resources/scripts/api/admin/nodes/getNodes.ts new file mode 100644 index 000000000..59e87ceb4 --- /dev/null +++ b/resources/scripts/api/admin/nodes/getNodes.ts @@ -0,0 +1,107 @@ +import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; +import { Database, rawDataToDatabase } from '@/api/admin/databases/getDatabases'; +import { Location, rawDataToLocation } from '@/api/admin/locations/getLocations'; + +export interface Node { + id: number; + uuid: string; + public: boolean; + name: string; + description: string | null; + locationId: number; + databaseHostId: number | null; + fqdn: string; + listenPortHTTP: number; + publicPortHTTP: number; + listenPortSFTP: number; + publicPortSFTP: number; + scheme: string; + behindProxy: boolean; + maintenanceMode: boolean; + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + uploadSize: number; + daemonBase: string; + createdAt: Date; + updatedAt: Date; + + relations: { + databaseHost: Database | undefined; + location: Location | undefined; + } +} + +export const rawDataToNode = ({ attributes }: FractalResponseData): Node => ({ + id: attributes.id, + uuid: attributes.uuid, + public: attributes.public, + name: attributes.name, + description: attributes.description, + locationId: attributes.location_id, + databaseHostId: attributes.database_host_id, + fqdn: attributes.fqdn, + listenPortHTTP: attributes.listen_port_http, + publicPortHTTP: attributes.public_port_http, + listenPortSFTP: attributes.listen_port_sftp, + publicPortSFTP: attributes.public_port_sftp, + scheme: attributes.scheme, + behindProxy: attributes.behind_proxy, + maintenanceMode: attributes.maintenance_mode, + memory: attributes.memory, + memoryOverallocate: attributes.memory_overallocate, + disk: attributes.disk, + diskOverallocate: attributes.disk_overallocate, + uploadSize: attributes.upload_size, + daemonBase: attributes.daemon_base, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + relations: { + // eslint-disable-next-line camelcase + databaseHost: attributes.relationships?.database_host !== undefined && attributes.relationships?.database_host.object !== 'null_resource' ? rawDataToDatabase(attributes.relationships.database_host as FractalResponseData) : undefined, + location: attributes.relationships?.location !== undefined ? rawDataToLocation(attributes.relationships.location as FractalResponseData) : undefined, + }, +}); + +export interface Filters { + id?: string; + uuid?: string; + name?: string; + image?: string; + /* eslint-disable camelcase */ + external_id?: string; + /* eslint-enable camelcase */ +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'nodes', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/nodes', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToNode), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/nodes/updateNode.ts b/resources/scripts/api/admin/nodes/updateNode.ts new file mode 100644 index 000000000..623f66931 --- /dev/null +++ b/resources/scripts/api/admin/nodes/updateNode.ts @@ -0,0 +1,21 @@ +import http from '@/api/http'; +import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; + +export default (id: number, node: Partial, include: string[] = []): Promise => { + const data = {}; + + Object.keys(node).forEach((key) => { + const key2 = key + .replace('HTTP', 'Http') + .replace('SFTP', 'Sftp') + .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); + // @ts-ignore + data[key2] = node[key]; + }); + + return new Promise((resolve, reject) => { + http.patch(`/api/application/nodes/${id}`, data, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToNode(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/roles.ts b/resources/scripts/api/admin/roles.ts new file mode 100644 index 000000000..8fb5c4c85 --- /dev/null +++ b/resources/scripts/api/admin/roles.ts @@ -0,0 +1,103 @@ +import http, { getPaginationSet, PaginatedResult } from '@/api/http'; +import { Transformers, UserRole } from '@definitions/admin'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin/index'; + +export interface Filters { + id?: string; + name?: string; +} + +export const Context = createContext(); + +const createRole = (name: string, description: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/roles', { + name, description, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUserRole(data))) + .catch(reject); + }); +}; + +const deleteRole = (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/roles/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; + +const getRole = (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/roles/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUserRole(data))) + .catch(reject); + }); +}; + +const searchRoles = (filters?: { name?: string }): Promise => { + const params = {}; + if (filters !== undefined) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + return new Promise((resolve, reject) => { + http.get('/api/application/roles', { params }) + .then(response => resolve( + (response.data.data || []).map(Transformers.toUserRole) + )) + .catch(reject); + }); +}; + +const updateRole = (id: number, name: string, description: string | null, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch(`/api/application/roles/${id}`, { + name, description, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUserRole(data))) + .catch(reject); + }); +}; + +const getRoles = (include: string[] = []) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + return useSWR>([ 'roles', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/roles', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(Transformers.toUserRole), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; + +export { + createRole, + deleteRole, + getRole, + searchRoles, + updateRole, + getRoles, +}; diff --git a/resources/scripts/api/admin/server.ts b/resources/scripts/api/admin/server.ts new file mode 100644 index 000000000..26f9ba6b5 --- /dev/null +++ b/resources/scripts/api/admin/server.ts @@ -0,0 +1,99 @@ +import useSWR, { SWRResponse } from 'swr'; +import { AxiosError } from 'axios'; +import { useParams } from 'react-router-dom'; +import http from '@/api/http'; +import { Model, UUID, withRelationships, WithRelationships } from '@/api/admin/index'; +import { Allocation, Node } from '@/api/admin/node'; +import { Transformers, User } from '@definitions/admin'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +/** + * Defines the limits for a server that exists on the Panel. + */ +interface ServerLimits { + memory: number; + swap: number; + disk: number; + io: number; + cpu: number; + threads: string | null; + oomDisabled: boolean; +} + +export interface ServerVariable extends EggVariable { + serverValue: string; +} + +/** + * Defines a single server instance that is returned from the Panel's admin + * API endpoints. + */ +export interface Server extends Model { + id: number; + uuid: UUID; + externalId: string | null; + identifier: string; + name: string; + description: string; + status: string; + userId: number; + nodeId: number; + allocationId: number; + eggId: number; + nestId: number; + limits: ServerLimits; + featureLimits: { + databases: number; + allocations: number; + backups: number; + }; + container: { + startup: string | null; + image: string; + environment: Record; + }; + createdAt: Date; + updatedAt: Date; + relationships: { + allocations?: Allocation[]; + nest?: Nest; + egg?: Egg; + node?: Node; + user?: User; + variables?: ServerVariable[]; + }; +} + +/** + * A standard API response with the minimum viable details for the frontend + * to correctly render a server. + */ +type LoadedServer = WithRelationships; + +/** + * Fetches a server from the API and ensures that the allocations, user, and + * node data is loaded. + */ +export const getServer = async (id: number | string): Promise => { + const { data } = await http.get(`/api/application/servers/${id}`, { + params: { + include: ['allocations', 'user', 'node', 'variables'], + }, + }); + + return withRelationships(Transformers.toServer(data), 'allocations', 'user', 'node', 'variables'); +}; + +/** + * Returns an SWR instance by automatically loading in the server for the currently + * loaded route match in the admin area. + */ +export const useServerFromRoute = (): SWRResponse => { + const params = useParams<'id'>(); + + return useSWR(`/api/application/servers/${params.id}`, async () => getServer(Number(params.id)), { + revalidateOnMount: false, + revalidateOnFocus: false, + }); +}; diff --git a/resources/scripts/api/admin/servers/createServer.ts b/resources/scripts/api/admin/servers/createServer.ts new file mode 100644 index 000000000..3fd94ca62 --- /dev/null +++ b/resources/scripts/api/admin/servers/createServer.ts @@ -0,0 +1,80 @@ +import http from '@/api/http'; +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; + +export interface CreateServerRequest { + externalId: string; + name: string; + description: string | null; + ownerId: number; + nodeId: number; + + limits: { + memory: number; + swap: number; + disk: number; + io: number; + cpu: number; + threads: string; + oomDisabled: boolean; + } + + featureLimits: { + allocations: number; + backups: number; + databases: number; + }; + + allocation: { + default: number; + additional: number[]; + }; + + startup: string; + environment: Record; + eggId: number; + image: string; + skipScripts: boolean; + startOnCompletion: boolean; +} + +export default (r: CreateServerRequest, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/application/servers', { + externalId: r.externalId, + name: r.name, + description: r.description, + owner_id: r.ownerId, + node_id: r.nodeId, + + limits: { + cpu: r.limits.cpu, + disk: r.limits.disk, + io: r.limits.io, + memory: r.limits.memory, + swap: r.limits.swap, + threads: r.limits.threads, + oom_killer: r.limits.oomDisabled, + }, + + feature_limits: { + allocations: r.featureLimits.allocations, + backups: r.featureLimits.backups, + databases: r.featureLimits.databases, + }, + + allocation: { + default: r.allocation.default, + additional: r.allocation.additional, + }, + + startup: r.startup, + environment: r.environment, + egg_id: r.eggId, + image: r.image, + skip_scripts: r.skipScripts, + start_on_completion: r.startOnCompletion, + }, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToServer(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/servers/deleteServer.ts b/resources/scripts/api/admin/servers/deleteServer.ts new file mode 100644 index 000000000..579559518 --- /dev/null +++ b/resources/scripts/api/admin/servers/deleteServer.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/servers/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/servers/getServer.ts b/resources/scripts/api/admin/servers/getServer.ts new file mode 100644 index 000000000..be10f0216 --- /dev/null +++ b/resources/scripts/api/admin/servers/getServer.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; + +export default (id: number, include: string[]): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/servers/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(rawDataToServer(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/servers/getServers.ts b/resources/scripts/api/admin/servers/getServers.ts new file mode 100644 index 000000000..6d73f070b --- /dev/null +++ b/resources/scripts/api/admin/servers/getServers.ts @@ -0,0 +1,177 @@ +import { Allocation, rawDataToAllocation } from '@/api/admin/nodes/getAllocations'; +import { useContext } from 'react'; +import useSWR from 'swr'; +import { createContext } from '@/api/admin'; +import http, { FractalResponseData, FractalResponseList, getPaginationSet, PaginatedResult } from '@/api/http'; +import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; +import { Node, rawDataToNode } from '@/api/admin/nodes/getNodes'; +import { Transformers, User } from '@definitions/admin'; + +export interface ServerVariable { + id: number; + eggId: number; + name: string; + description: string; + envVariable: string; + defaultValue: string; + userViewable: boolean; + userEditable: boolean; + rules: string; + required: boolean; + serverValue: string; + createdAt: Date; + updatedAt: Date; +} + +export const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ + id: attributes.id, + eggId: attributes.egg_id, + name: attributes.name, + description: attributes.description, + envVariable: attributes.env_variable, + defaultValue: attributes.default_value, + userViewable: attributes.user_viewable, + userEditable: attributes.user_editable, + rules: attributes.rules, + required: attributes.required, + serverValue: attributes.server_value, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), +}); + +export interface Server { + id: number; + externalId: string | null + uuid: string; + identifier: string; + name: string; + description: string; + status: string; + + limits: { + memory: number; + swap: number; + disk: number; + io: number; + cpu: number; + threads: string | null; + oomDisabled: boolean; + } + + featureLimits: { + databases: number; + allocations: number; + backups: number; + } + + ownerId: number; + nodeId: number; + allocationId: number; + nestId: number; + eggId: number; + + container: { + startup: string; + image: string; + environment: Map; + } + + createdAt: Date; + updatedAt: Date; + + relations: { + allocations?: Allocation[]; + egg?: Egg; + node?: Node; + user?: User; + variables: ServerVariable[]; + } +} + +export const rawDataToServer = ({ attributes }: FractalResponseData): Server => ({ + id: attributes.id, + externalId: attributes.external_id, + uuid: attributes.uuid, + identifier: attributes.identifier, + name: attributes.name, + description: attributes.description, + status: attributes.status, + + limits: { + memory: attributes.limits.memory, + swap: attributes.limits.swap, + disk: attributes.limits.disk, + io: attributes.limits.io, + cpu: attributes.limits.cpu, + threads: attributes.limits.threads, + oomDisabled: attributes.limits.oom_disabled, + }, + + featureLimits: { + databases: attributes.feature_limits.databases, + allocations: attributes.feature_limits.allocations, + backups: attributes.feature_limits.backups, + }, + + ownerId: attributes.owner_id, + nodeId: attributes.node_id, + allocationId: attributes.allocation_id, + nestId: attributes.nest_id, + eggId: attributes.egg_id, + + container: { + startup: attributes.container.startup, + image: attributes.container.image, + environment: attributes.container.environment, + }, + + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + + relations: { + allocations: ((attributes.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToAllocation), + egg: attributes.relationships?.egg?.object === 'egg' ? rawDataToEgg(attributes.relationships.egg as FractalResponseData) : undefined, + node: attributes.relationships?.node?.object === 'node' ? rawDataToNode(attributes.relationships.node as FractalResponseData) : undefined, + user: attributes.relationships?.user?.object === 'user' ? Transformers.toUser(attributes.relationships.user as FractalResponseData) : undefined, + variables: ((attributes.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerVariable), + }, +}) as Server; + +export interface Filters { + id?: string; + uuid?: string; + name?: string; + /* eslint-disable camelcase */ + owner_id?: string; + node_id?: string; + external_id?: string; + /* eslint-enable camelcase */ +} + +export const Context = createContext(); + +export default (include: string[] = []) => { + const { page, filters, sort, sortDirection } = useContext(Context); + + const params = {}; + if (filters !== null) { + Object.keys(filters).forEach(key => { + // @ts-ignore + params['filter[' + key + ']'] = filters[key]; + }); + } + + if (sort !== null) { + // @ts-ignore + params.sort = (sortDirection ? '-' : '') + sort; + } + + return useSWR>([ 'servers', page, filters, sort, sortDirection ], async () => { + const { data } = await http.get('/api/application/servers', { params: { include: include.join(','), page, ...params } }); + + return ({ + items: (data.data || []).map(rawDataToServer), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/admin/servers/updateServer.ts b/resources/scripts/api/admin/servers/updateServer.ts new file mode 100644 index 000000000..e74b7422a --- /dev/null +++ b/resources/scripts/api/admin/servers/updateServer.ts @@ -0,0 +1,64 @@ +import http from '@/api/http'; +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; + +export interface Values { + externalId: string; + name: string; + ownerId: number; + + limits: { + memory: number; + swap: number; + disk: number; + io: number; + cpu: number; + threads: string; + oomDisabled: boolean; + } + + featureLimits: { + allocations: number; + backups: number; + databases: number; + } + + allocationId: number; + addAllocations: number[]; + removeAllocations: number[]; +} + +export default (id: number, server: Partial, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch( + `/api/application/servers/${id}`, + { + external_id: server.externalId, + name: server.name, + owner_id: server.ownerId, + + limits: { + memory: server.limits?.memory, + swap: server.limits?.swap, + disk: server.limits?.disk, + io: server.limits?.io, + cpu: server.limits?.cpu, + threads: server.limits?.threads, + oom_killer: server.limits?.oomDisabled, + }, + + feature_limits: { + allocations: server.featureLimits?.allocations, + backups: server.featureLimits?.backups, + databases: server.featureLimits?.databases, + }, + + allocation_id: server.allocationId, + add_allocations: server.addAllocations, + remove_allocations: server.removeAllocations, + }, + { params: { include: include.join(',') } } + ) + .then(({ data }) => resolve(rawDataToServer(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/servers/updateServerStartup.ts b/resources/scripts/api/admin/servers/updateServerStartup.ts new file mode 100644 index 000000000..7dec26e30 --- /dev/null +++ b/resources/scripts/api/admin/servers/updateServerStartup.ts @@ -0,0 +1,28 @@ +import http from '@/api/http'; +import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; + +export interface Values { + startup: string; + environment: Record; + eggId: number; + image: string; + skipScripts: boolean; +} + +export default (id: number, values: Partial, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.patch( + `/api/application/servers/${id}/startup`, + { + startup: values.startup !== '' ? values.startup : null, + environment: values.environment, + egg_id: values.eggId, + image: values.image, + skip_scripts: values.skipScripts, + }, + { params: { include: include.join(',') } } + ) + .then(({ data }) => resolve(rawDataToServer(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/users.ts b/resources/scripts/api/admin/users.ts new file mode 100644 index 000000000..817721cb3 --- /dev/null +++ b/resources/scripts/api/admin/users.ts @@ -0,0 +1,96 @@ +import http, { + FractalPaginatedResponse, + PaginatedResult, + QueryBuilderParams, + getPaginationSet, + withQueryBuilderParams, +} from '@/api/http'; +import { Transformers, User } from '@definitions/admin'; +import useSWR, { SWRConfiguration, SWRResponse } from 'swr'; +import { AxiosError } from 'axios'; + +export interface UpdateUserValues { + externalId: string; + username: string; + email: string; + password: string; + adminRoleId: number | null; + rootAdmin: boolean; +} + +const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const; +type Filters = typeof filters[number]; + +const useGetUsers = ( + params?: QueryBuilderParams, + config?: SWRConfiguration, +): SWRResponse, AxiosError> => { + return useSWR>( + ['/api/application/users', JSON.stringify(params)], + async () => { + const { data } = await http.get('/api/application/users', { + params: withQueryBuilderParams(params), + }); + + return getPaginationSet(data, Transformers.toUser); + }, + config || { revalidateOnMount: true, revalidateOnFocus: false }, + ); +}; + +const getUser = (id: number, include: string[] = []): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/application/users/${id}`, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUser(data))) + .catch(reject); + }); +}; + +const searchUserAccounts = async (params: QueryBuilderParams<'username' | 'email'>): Promise => { + const { data } = await http.get('/api/application/users', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(Transformers.toUser); +}; + +const createUser = (values: UpdateUserValues, include: string[] = []): Promise => { + const data = {}; + Object.keys(values).forEach(k => { + // @ts-ignore + data[k.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`)] = values[k]; + }); + + return new Promise((resolve, reject) => { + http.post('/api/application/users', data, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUser(data))) + .catch(reject); + }); +}; + +const updateUser = (id: number, values: Partial, include: string[] = []): Promise => { + const data = {}; + Object.keys(values).forEach(k => { + // Don't set password if it is empty. + if (k === 'password' && values[k] === '') { + return; + } + // @ts-ignore + data[k.replace(/[A-Z]/g, l => `_${l.toLowerCase()}`)] = values[k]; + }); + return new Promise((resolve, reject) => { + http.patch(`/api/application/users/${id}`, data, { params: { include: include.join(',') } }) + .then(({ data }) => resolve(Transformers.toUser(data))) + .catch(reject); + }); +}; + +const deleteUser = (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/users/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; + +export { useGetUsers, getUser, searchUserAccounts, createUser, updateUser, deleteUser }; diff --git a/resources/scripts/api/definitions/admin/index.ts b/resources/scripts/api/definitions/admin/index.ts new file mode 100644 index 000000000..39ac1f45a --- /dev/null +++ b/resources/scripts/api/definitions/admin/index.ts @@ -0,0 +1,2 @@ +export * from './models.d'; +export { default as Transformers } from './transformers'; diff --git a/resources/scripts/api/definitions/admin/models.d.ts b/resources/scripts/api/definitions/admin/models.d.ts new file mode 100644 index 000000000..627a448f9 --- /dev/null +++ b/resources/scripts/api/definitions/admin/models.d.ts @@ -0,0 +1,29 @@ +import { ModelWithRelationships, UUID } from '@/api/definitions'; +import { Server } from '@/api/admin/server'; + +interface User extends ModelWithRelationships { + id: number; + uuid: UUID; + externalId: string; + username: string; + email: string; + language: string; + adminRoleId: number | null; + roleName: string; + isRootAdmin: boolean; + isUsingTwoFactor: boolean; + avatarUrl: string; + createdAt: Date; + updatedAt: Date; + relationships: { + role: UserRole | null; + // TODO: just use an API call, this is probably a bad idea for performance. + servers?: Server[]; + }; +} + +interface UserRole extends ModelWithRelationships { + id: number; + name: string; + description: string; +} diff --git a/resources/scripts/api/definitions/admin/transformers.ts b/resources/scripts/api/definitions/admin/transformers.ts new file mode 100644 index 000000000..07095ab07 --- /dev/null +++ b/resources/scripts/api/definitions/admin/transformers.ts @@ -0,0 +1,212 @@ +/* eslint-disable camelcase */ +import { Allocation, Node } from '@/api/admin/node'; +import { Server, ServerVariable } from '@/api/admin/server'; +import { FractalResponseData, FractalResponseList } from '@/api/http'; +import * as Models from '@definitions/admin/models'; +import { Location } from '@/api/admin/location'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; + +function transform (data: undefined, transformer: (callback: FractalResponseData) => T, missing?: M): undefined; +function transform (data: FractalResponseData | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T | M | undefined; +function transform (data: FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T[] | undefined; +function transform (data: FractalResponseData | FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing = undefined) { + if (data === undefined) return undefined; + + if (isList(data)) { + return data.data.map(transformer); + } + + return !data ? missing : transformer(data); +} + +export default class Transformers { + static toServer = ({ attributes }: FractalResponseData): Server => { + const { oom_disabled, ...limits } = attributes.limits; + const { allocations, egg, nest, node, user, variables } = attributes.relationships || {}; + + return { + id: attributes.id, + uuid: attributes.uuid, + externalId: attributes.external_id, + identifier: attributes.identifier, + name: attributes.name, + description: attributes.description, + status: attributes.status, + userId: attributes.owner_id, + nodeId: attributes.node_id, + allocationId: attributes.allocation_id, + eggId: attributes.egg_id, + nestId: attributes.nest_id, + limits: { ...limits, oomDisabled: oom_disabled }, + featureLimits: attributes.feature_limits, + container: attributes.container, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + allocations: transform(allocations as FractalResponseList | undefined, this.toAllocation), + nest: transform(nest as FractalResponseData | undefined, this.toNest), + egg: transform(egg as FractalResponseData | undefined, this.toEgg), + node: transform(node as FractalResponseData | undefined, this.toNode), + user: transform(user as FractalResponseData | undefined, this.toUser), + variables: transform(variables as FractalResponseList | undefined, this.toServerEggVariable), + }, + }; + }; + + static toNode = ({ attributes }: FractalResponseData): Node => { + return { + id: attributes.id, + uuid: attributes.uuid, + isPublic: attributes.public, + locationId: attributes.location_id, + databaseHostId: attributes.database_host_id, + name: attributes.name, + description: attributes.description, + fqdn: attributes.fqdn, + ports: { + http: { + public: attributes.publicPortHttp, + listen: attributes.listenPortHttp, + }, + sftp: { + public: attributes.publicPortSftp, + listen: attributes.listenPortSftp, + }, + }, + scheme: attributes.scheme, + isBehindProxy: attributes.behindProxy, + isMaintenanceMode: attributes.maintenance_mode, + memory: attributes.memory, + memoryOverallocate: attributes.memory_overallocate, + disk: attributes.disk, + diskOverallocate: attributes.disk_overallocate, + uploadSize: attributes.upload_size, + daemonBase: attributes.daemonBase, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + location: transform(attributes.relationships?.location as FractalResponseData, this.toLocation), + }, + }; + }; + + static toUserRole = ({ attributes }: FractalResponseData): Models.UserRole => ({ + id: attributes.id, + name: attributes.name, + description: attributes.description, + relationships: {}, + }); + + static toUser = ({ attributes }: FractalResponseData): Models.User => { + return { + id: attributes.id, + uuid: attributes.uuid, + externalId: attributes.external_id, + username: attributes.username, + email: attributes.email, + language: attributes.language, + adminRoleId: attributes.adminRoleId || null, + roleName: attributes.role_name, + isRootAdmin: attributes.root_admin, + isUsingTwoFactor: attributes['2fa'] || false, + avatarUrl: attributes.avatar_url, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + role: transform(attributes.relationships?.role as FractalResponseData, this.toUserRole) || null, + }, + }; + }; + + static toLocation = ({ attributes }: FractalResponseData): Location => ({ + id: attributes.id, + short: attributes.short, + long: attributes.long, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nodes: transform(attributes.relationships?.node as FractalResponseList, this.toNode), + }, + }); + + static toEgg = ({ attributes }: FractalResponseData): Egg => ({ + id: attributes.id, + uuid: attributes.uuid, + nestId: attributes.nest_id, + author: attributes.author, + name: attributes.name, + description: attributes.description, + features: attributes.features, + dockerImages: attributes.docker_images, + configFiles: attributes.config?.files, + configStartup: attributes.config?.startup, + configStop: attributes.config?.stop, + configFrom: attributes.config?.extends, + startup: attributes.startup, + copyScriptFrom: attributes.copy_script_from, + scriptContainer: attributes.script?.container, + scriptEntry: attributes.script?.entry, + scriptIsPrivileged: attributes.script?.privileged, + scriptInstall: attributes.script?.install, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nest: transform(attributes.relationships?.nest as FractalResponseData, this.toNest), + variables: transform(attributes.relationships?.variables as FractalResponseList, this.toEggVariable), + }, + }); + + static toEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ + id: attributes.id, + eggId: attributes.egg_id, + name: attributes.name, + description: attributes.description, + environmentVariable: attributes.env_variable, + defaultValue: attributes.default_value, + isUserViewable: attributes.user_viewable, + isUserEditable: attributes.user_editable, + // isRequired: attributes.required, + rules: attributes.rules, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: {}, + }); + + static toServerEggVariable = (data: FractalResponseData): ServerVariable => ({ + ...this.toEggVariable(data), + serverValue: data.attributes.server_value, + }); + + static toAllocation = ({ attributes }: FractalResponseData): Allocation => ({ + id: attributes.id, + ip: attributes.ip, + port: attributes.port, + alias: attributes.alias || null, + isAssigned: attributes.assigned, + relationships: { + node: transform(attributes.relationships?.node as FractalResponseData, this.toNode), + server: transform(attributes.relationships?.server as FractalResponseData, this.toServer), + }, + getDisplayText (): string { + const raw = `${this.ip}:${this.port}`; + + return !this.alias ? raw : `${this.alias} (${raw})`; + }, + }); + + static toNest = ({ attributes }: FractalResponseData): Nest => ({ + id: attributes.id, + uuid: attributes.uuid, + author: attributes.author, + name: attributes.name, + description: attributes.description, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + eggs: transform(attributes.relationships?.eggs as FractalResponseList, this.toEgg), + }, + }); +} diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index c72ce426f..ff53fb80e 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -11,10 +11,12 @@ import Spinner from '@/components/elements/Spinner'; import { store } from '@/state'; import { ServerContext } from '@/state/server'; import { SiteSettings } from '@/state/settings'; +import { AdminContext } from '@/state/admin'; +const AdminRouter = lazy(() => import('@/routers/AdminRouter')); +const AuthenticationRouter = lazy(() => import('@/routers/AuthenticationRouter')); const DashboardRouter = lazy(() => import('@/routers/DashboardRouter')); const ServerRouter = lazy(() => import('@/routers/ServerRouter')); -const AuthenticationRouter = lazy(() => import('@/routers/AuthenticationRouter')); interface ExtendedWindow extends Window { SiteConfiguration?: SiteSettings; @@ -86,6 +88,17 @@ function App() { } /> + + + + + + } + /> + ( +
+ +
+ {typeof title === 'string' ? ( +

+ {icon && } + {title} +

+ ) : ( + title + )} + {button} +
+
{children}
+
+); + +export default AdminBox; diff --git a/resources/scripts/components/admin/AdminCheckbox.tsx b/resources/scripts/components/admin/AdminCheckbox.tsx new file mode 100644 index 000000000..32ba2b00e --- /dev/null +++ b/resources/scripts/components/admin/AdminCheckbox.tsx @@ -0,0 +1,36 @@ +import type { ChangeEvent } from 'react'; +import tw, { styled } from 'twin.macro'; + +import Input from '@/components/elements/Input'; + +export const TableCheckbox = styled(Input)` + && { + ${tw`border-neutral-500 bg-transparent`}; + + &:not(:checked) { + ${tw`hover:border-neutral-300`}; + } + } +`; + +export default ({ + name, + checked, + onChange, +}: { + name: string; + checked: boolean; + onChange(e: ChangeEvent): void; +}) => { + return ( +
+ +
+ ); +}; diff --git a/resources/scripts/components/admin/AdminContentBlock.tsx b/resources/scripts/components/admin/AdminContentBlock.tsx new file mode 100644 index 000000000..e182026e8 --- /dev/null +++ b/resources/scripts/components/admin/AdminContentBlock.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; +import { useEffect } from 'react'; +// import { CSSTransition } from 'react-transition-group'; +import tw from 'twin.macro'; +import FlashMessageRender from '@/components/FlashMessageRender'; + +const AdminContentBlock: React.FC<{ + children: ReactNode; + title?: string; + showFlashKey?: string; + className?: string; +}> = ({ children, title, showFlashKey }) => { + useEffect(() => { + if (!title) { + return; + } + + document.title = `Admin | ${title}`; + }, [title]); + + return ( + // + <> + {showFlashKey && } + {children} + {/*

+ © 2015 - 2021  + + Pterodactyl Software + +

*/} + + //
+ ); +}; + +export default AdminContentBlock; diff --git a/resources/scripts/components/admin/AdminTable.tsx b/resources/scripts/components/admin/AdminTable.tsx new file mode 100644 index 000000000..60c544a0a --- /dev/null +++ b/resources/scripts/components/admin/AdminTable.tsx @@ -0,0 +1,348 @@ +import { debounce } from 'debounce'; +import type { ChangeEvent, MouseEvent, ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import tw, { styled } from 'twin.macro'; + +import type { ListContext as TableHooks } from '@/api/admin'; +import type { PaginatedResult, PaginationDataSet } from '@/api/http'; +import { TableCheckbox } from '@/components/admin/AdminCheckbox'; +import Input from '@/components/elements/Input'; +import InputSpinner from '@/components/elements/InputSpinner'; +import Spinner from '@/components/elements/Spinner'; + +export function useTableHooks(initialState?: T | (() => T)): TableHooks { + const [page, setPage] = useState(1); + const [filters, setFilters] = useState(initialState || null); + const [sort, setSortState] = useState(null); + const [sortDirection, setSortDirection] = useState(false); + + const setSort = (newSort: string | null) => { + if (sort === newSort) { + setSortDirection(!sortDirection); + } else { + setSortState(newSort); + setSortDirection(false); + } + }; + + return { page, setPage, filters, setFilters, sort, setSort, sortDirection, setSortDirection }; +} + +export const TableHeader = ({ + name, + onClick, + direction, +}: { + name?: string; + onClick?: (e: MouseEvent) => void; + direction?: number | null; +}) => { + if (!name) { + return ; + } + + return ( + + + + {name} + + + {direction !== undefined ? ( +
+ + {direction === null || direction === 1 ? ( + + ) : null} + {direction === null || direction === 2 ? ( + + ) : null} + +
+ ) : null} +
+ + ); +}; + +export const TableHead = ({ children }: { children: ReactNode }) => { + return ( + + + + {children} + + + ); +}; + +export const TableBody = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +export const TableRow = ({ children }: { children: ReactNode }) => { + return {children}; +}; + +interface Props { + data?: PaginatedResult; + onPageSelect: (page: number) => void; + + children: ReactNode; +} + +const PaginationButton = styled.button<{ active?: boolean }>` + ${tw`relative items-center px-3 py-1 -ml-px text-sm font-normal leading-5 transition duration-150 ease-in-out border border-neutral-500 focus:z-10 focus:outline-none focus:border-primary-300 inline-flex`}; + + ${props => + props.active ? tw`bg-neutral-500 text-neutral-50` : tw`bg-neutral-600 text-neutral-200 hover:text-neutral-50`}; +`; + +const PaginationArrow = styled.button` + ${tw`relative inline-flex items-center px-1 py-1 text-sm font-medium leading-5 transition duration-150 ease-in-out border border-neutral-500 bg-neutral-600 text-neutral-400 hover:text-neutral-50 focus:z-10 focus:outline-none focus:border-primary-300`}; + + &:disabled { + ${tw`bg-neutral-700`} + } + + &:hover:disabled { + ${tw`text-neutral-400 cursor-default`}; + } +`; + +export function Pagination({ data, onPageSelect, children }: Props) { + let pagination: PaginationDataSet; + if (data === undefined) { + pagination = { + total: 0, + count: 0, + perPage: 0, + currentPage: 1, + totalPages: 1, + }; + } else { + pagination = data.pagination; + } + + const setPage = (page: number) => { + if (page < 1 || page > pagination.totalPages) { + return; + } + + onPageSelect(page); + }; + + const isFirstPage = pagination.currentPage === 1; + const isLastPage = pagination.currentPage >= pagination.totalPages; + + const pages = []; + + if (pagination.totalPages < 7) { + for (let i = 1; i <= pagination.totalPages; i++) { + pages.push(i); + } + } else { + // Don't ask me how this works, all I know is that this code will always have 7 items in the pagination, + // and keeps the current page centered if it is not too close to the start or end. + let start = Math.max(pagination.currentPage - 3, 1); + const end = Math.min( + pagination.totalPages, + pagination.currentPage + (pagination.currentPage < 4 ? 7 - pagination.currentPage : 3), + ); + + while (start !== 1 && end - start !== 6) { + start--; + } + + for (let i = start; i <= end; i++) { + pages.push(i); + } + } + + return ( + <> + {children} + +
+

+ Showing{' '} + + {(pagination.currentPage - 1) * pagination.perPage + (pagination.total > 0 ? 1 : 0)} + {' '} + to{' '} + + {(pagination.currentPage - 1) * pagination.perPage + pagination.count} + {' '} + of {pagination.total} results +

+ + {isFirstPage && isLastPage ? null : ( +
+ +
+ )} +
+ + ); +} + +export const Loading = () => { + return ( +
+ +
+ ); +}; + +export const NoItems = ({ className }: { className?: string }) => { + return ( +
+
+ {'No +
+ +

+ No items could be found, it's almost like they are hiding. +

+
+ ); +}; + +interface Params { + checked: boolean; + onSelectAllClick: (e: ChangeEvent) => void; + onSearch?: (query: string) => Promise; + + children: ReactNode; +} + +export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children }: Params) => { + const [loading, setLoading] = useState(false); + const [inputText, setInputText] = useState(''); + + const search = useCallback( + debounce((query: string) => { + if (onSearch === undefined) { + return; + } + + setLoading(true); + onSearch(query).then(() => setLoading(false)); + }, 200), + [], + ); + + return ( + <> +
+
+ + + + + +
+ +
+ + { + setInputText(e.currentTarget.value); + search(e.currentTarget.value); + }} + /> + +
+
+ + {children} + + ); +}; + +export default ({ children }: { children: ReactNode }) => { + return ( +
+
{children}
+
+ ); +}; diff --git a/resources/scripts/components/admin/Sidebar.tsx b/resources/scripts/components/admin/Sidebar.tsx new file mode 100644 index 000000000..7ff60cad2 --- /dev/null +++ b/resources/scripts/components/admin/Sidebar.tsx @@ -0,0 +1,87 @@ +import tw, { css, styled } from 'twin.macro'; + +import { withSubComponents } from '@/components/helpers'; + +const Wrapper = styled.div` + ${tw`w-full flex flex-col px-4`}; + + & > a { + ${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-4`}; + ${tw`hover:text-neutral-50`}; + + & > svg { + ${tw`h-6 w-6 flex flex-shrink-0`}; + } + + & > span { + ${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`}; + } + + &:active, + &.active { + ${tw`text-neutral-50 bg-neutral-800 rounded`}; + } + } +`; + +const Section = styled.div` + ${tw`h-[18px] font-header font-medium text-xs text-neutral-300 whitespace-nowrap uppercase ml-4 mb-1 select-none`}; + + &:not(:first-of-type) { + ${tw`mt-4`}; + } +`; + +const User = styled.div` + ${tw`h-16 w-full flex items-center bg-neutral-700 justify-center`}; +`; + +const Sidebar = styled.div<{ $collapsed?: boolean }>` + ${tw`h-screen hidden md:flex flex-col items-center flex-shrink-0 bg-neutral-900 overflow-x-hidden ease-linear`}; + ${tw`transition-[width] duration-150 ease-in`}; + ${tw`w-[17.5rem]`}; + + & > a { + ${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-8`}; + ${tw`hover:text-neutral-50`}; + + & > svg { + ${tw`transition-none h-6 w-6 flex flex-shrink-0`}; + } + + & > span { + ${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`}; + } + } + + ${props => + props.$collapsed && + css` + ${tw`w-20`}; + + ${Section} { + ${tw`invisible`}; + } + + ${Wrapper} { + ${tw`px-5`}; + + & > a { + ${tw`justify-center px-0`}; + } + } + + & > a { + ${tw`justify-center px-4`}; + } + + & > a > span, + ${User} > div, + ${User} > a, + ${Wrapper} > a > span { + ${tw`hidden`}; + } + `}; +`; + +export default withSubComponents(Sidebar, { Section, Wrapper, User }); diff --git a/resources/scripts/components/admin/SubNavigation.tsx b/resources/scripts/components/admin/SubNavigation.tsx new file mode 100644 index 000000000..5af3f28f0 --- /dev/null +++ b/resources/scripts/components/admin/SubNavigation.tsx @@ -0,0 +1,42 @@ +import type { ComponentType, ReactNode } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw, { styled } from 'twin.macro'; + +export const SubNavigation = styled.div` + ${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`}; + + & > a { + ${tw`flex flex-row items-center h-full px-4 border-b text-neutral-300 text-base whitespace-nowrap border-transparent`}; + + & > svg { + ${tw`w-6 h-6 mr-2`}; + } + + &:active, + &.active { + ${tw`text-primary-300 border-primary-300`}; + } + } +`; + +interface Props { + to: string; + name: string; +} + +interface PropsWithIcon extends Props { + icon: ComponentType; + children?: never; +} + +interface PropsWithoutIcon extends Props { + icon?: never; + children: ReactNode; +} + +export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => ( + + {IconComponent ? : children} + {name} + +); diff --git a/resources/scripts/components/admin/databases/DatabaseDeleteButton.tsx b/resources/scripts/components/admin/databases/DatabaseDeleteButton.tsx new file mode 100644 index 000000000..be08b56fe --- /dev/null +++ b/resources/scripts/components/admin/databases/DatabaseDeleteButton.tsx @@ -0,0 +1,73 @@ +import { Actions, useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteDatabase from '@/api/admin/databases/deleteDatabase'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + databaseId: number; + onDeleted: () => void; +} + +export default ({ databaseId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('database'); + + deleteDatabase(databaseId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'database', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this database host? This action will delete all knowledge of databases + created on this host but not the databases themselves. + + + + + ); +}; diff --git a/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx b/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx new file mode 100644 index 000000000..89d8df46f --- /dev/null +++ b/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx @@ -0,0 +1,235 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; +import { number, object, string } from 'yup'; + +import type { Database } from '@/api/admin/databases/getDatabases'; +import getDatabase from '@/api/admin/databases/getDatabase'; +import updateDatabase from '@/api/admin/databases/updateDatabase'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import DatabaseDeleteButton from '@/components/admin/databases/DatabaseDeleteButton'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + database: Database | undefined; + setDatabase: Action; +} + +export const Context = createContextStore({ + database: undefined, + + setDatabase: action((state, payload) => { + state.database = payload; + }), +}); + +export interface Values { + name: string; + host: string; + port: number; + username: string; + password: string; +} + +export interface Params { + title: string; + initialValues?: Values; + children?: React.ReactNode; + + onSubmit: (values: Values, helpers: FormikHelpers) => void; +} + +export const InformationContainer = ({ title, initialValues, children, onSubmit }: Params) => { + const submit = (values: Values, helpers: FormikHelpers) => { + onSubmit(values, helpers); + }; + + if (!initialValues) { + initialValues = { + name: '', + host: '', + port: 3306, + username: '', + password: '', + }; + } + + return ( + + {({ isSubmitting, isValid }) => ( + <> + + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+ +
+ {children} +
+ +
+
+
+
+ + )} +
+ ); +}; + +const EditInformationContainer = () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const database = Context.useStoreState(state => state.database); + const setDatabase = Context.useStoreActions(actions => actions.setDatabase); + + if (database === undefined) { + return <>; + } + + const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('database'); + + updateDatabase(database.id, name, host, port, username, password || undefined) + .then(() => setDatabase({ ...database, name, host, port, username })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'database', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+ navigate('/admin/databases')} /> +
+
+ ); +}; + +const DatabaseEditContainer = () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const database = Context.useStoreState(state => state.database); + const setDatabase = Context.useStoreActions(actions => actions.setDatabase); + + useEffect(() => { + clearFlashes('database'); + + getDatabase(Number(params.id)) + .then(database => setDatabase(database)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'database', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || database === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{database.name}

+

+ {database.getAddress()} +

+
+
+ + + + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/databases/DatabasesContainer.tsx b/resources/scripts/components/admin/databases/DatabasesContainer.tsx new file mode 100644 index 000000000..73ff3027c --- /dev/null +++ b/resources/scripts/components/admin/databases/DatabasesContainer.tsx @@ -0,0 +1,194 @@ +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/databases/getDatabases'; +import getDatabases, { Context as DatabasesContext } from '@/api/admin/databases/getDatabases'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import Button from '@/components/elements/Button'; +import CopyOnClick from '@/components/elements/CopyOnClick'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.databases.selectedDatabases.indexOf(id) >= 0); + const appendSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.appendSelectedDatabase); + const removeSelectedDatabase = AdminContext.useStoreActions(actions => actions.databases.removeSelectedDatabase); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedDatabase(id); + } else { + removeSelectedDatabase(id); + } + }} + /> + ); +}; + +const DatabasesContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(DatabasesContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: databases, error, isValidating } = getDatabases(); + + useEffect(() => { + if (!error) { + clearFlashes('databases'); + return; + } + + clearAndAddHttpError({ key: 'databases', error }); + }, [error]); + + const length = databases?.items?.length || 0; + + const setSelectedDatabases = AdminContext.useStoreActions(actions => actions.databases.setSelectedDatabases); + const selectedDatabasesLength = AdminContext.useStoreState(state => state.databases.selectedDatabases.length); + + const onSelectAllClick = (e: React.ChangeEvent) => { + setSelectedDatabases(e.currentTarget.checked ? databases?.items?.map(database => database.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedDatabases([]); + }, [page]); + + return ( + +
+
+

Database Hosts

+

+ Database hosts that servers can have databases created on. +

+
+ +
+ + + +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + + + + + + {databases !== undefined && + !error && + !isValidating && + length > 0 && + databases.items.map(database => ( + + + + + + + + + + + + ))} + +
+ + + + + {database.id} + + + + + {database.name} + + + + + {database.getAddress()} + + + + {database.username} +
+ + {databases === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/databases/NewDatabaseContainer.tsx b/resources/scripts/components/admin/databases/NewDatabaseContainer.tsx new file mode 100644 index 000000000..3bc0afa94 --- /dev/null +++ b/resources/scripts/components/admin/databases/NewDatabaseContainer.tsx @@ -0,0 +1,48 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; + +import createDatabase from '@/api/admin/databases/createDatabase'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { InformationContainer, Values } from '@/components/admin/databases/DatabaseEditContainer'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { ApplicationStore } from '@/state'; + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const submit = ({ name, host, port, username, password }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('database:create'); + + createDatabase(name, host, port, username, password) + .then(database => navigate(`/admin/databases/${database.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'database:create', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New Database Host

+

+ Add a new database host to the panel. +

+
+
+ + + + +
+ ); +}; diff --git a/resources/scripts/components/admin/locations/LocationDeleteButton.tsx b/resources/scripts/components/admin/locations/LocationDeleteButton.tsx new file mode 100644 index 000000000..9fb80954e --- /dev/null +++ b/resources/scripts/components/admin/locations/LocationDeleteButton.tsx @@ -0,0 +1,74 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteLocation from '@/api/admin/locations/deleteLocation'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + locationId: number; + onDeleted: () => void; +} + +export default ({ locationId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('location'); + + deleteLocation(locationId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'location', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this location? You may only delete a location if no nodes are assigned + to it. + + + + + ); +}; diff --git a/resources/scripts/components/admin/locations/LocationEditContainer.tsx b/resources/scripts/components/admin/locations/LocationEditContainer.tsx new file mode 100644 index 000000000..6c0e74c6f --- /dev/null +++ b/resources/scripts/components/admin/locations/LocationEditContainer.tsx @@ -0,0 +1,180 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; + +import type { Location } from '@/api/admin/locations/getLocations'; +import getLocation from '@/api/admin/locations/getLocation'; +import updateLocation from '@/api/admin/locations/updateLocation'; +import AdminBox from '@/components/admin/AdminBox'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import LocationDeleteButton from '@/components/admin/locations/LocationDeleteButton'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Spinner from '@/components/elements/Spinner'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + location: Location | undefined; + setLocation: Action; +} + +export const Context = createContextStore({ + location: undefined, + + setLocation: action((state, payload) => { + state.location = payload; + }), +}); + +interface Values { + short: string; + long: string; +} + +const EditInformationContainer = () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const location = Context.useStoreState(state => state.location); + const setLocation = Context.useStoreActions(actions => actions.setLocation); + + if (location === undefined) { + return <>; + } + + const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('location'); + + updateLocation(location.id, short, long) + .then(() => setLocation({ ...location, short, long })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'location', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( + <> + + + +
+
+ +
+ +
+ +
+ +
+
+ navigate('/admin/locations')} + /> +
+ +
+ +
+
+
+
+ + )} +
+ ); +}; + +const LocationEditContainer = () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const location = Context.useStoreState(state => state.location); + const setLocation = Context.useStoreActions(actions => actions.setLocation); + + useEffect(() => { + clearFlashes('location'); + + getLocation(Number(params.id)) + .then(location => setLocation(location)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'location', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || location === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{location.short}

+ {(location.long || '').length < 1 ? ( +

+ No long name +

+ ) : ( +

+ {location.long} +

+ )} +
+
+ + + + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/locations/LocationsContainer.tsx b/resources/scripts/components/admin/locations/LocationsContainer.tsx new file mode 100644 index 000000000..81ddd710a --- /dev/null +++ b/resources/scripts/components/admin/locations/LocationsContainer.tsx @@ -0,0 +1,186 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/locations/getLocations'; +import getLocations, { Context as LocationsContext } from '@/api/admin/locations/getLocations'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import NewLocationButton from '@/components/admin/locations/NewLocationButton'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.locations.selectedLocations.indexOf(id) >= 0); + const appendSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.appendSelectedLocation); + const removeSelectedLocation = AdminContext.useStoreActions(actions => actions.locations.removeSelectedLocation); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedLocation(id); + } else { + removeSelectedLocation(id); + } + }} + /> + ); +}; + +const LocationsContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(LocationsContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: locations, error, isValidating } = getLocations(); + + useEffect(() => { + if (!error) { + clearFlashes('locations'); + return; + } + + clearAndAddHttpError({ key: 'locations', error }); + }, [error]); + + const length = locations?.items?.length || 0; + + const setSelectedLocations = AdminContext.useStoreActions(actions => actions.locations.setSelectedLocations); + const selectedLocationsLength = AdminContext.useStoreState(state => state.locations.selectedLocations.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedLocations(e.currentTarget.checked ? locations?.items?.map(location => location.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ short: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedLocations([]); + }, [page]); + + return ( + +
+
+

Locations

+

+ All locations that nodes can be assigned to for easier categorization. +

+
+ +
+ +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('short')} + /> + setSort('long')} + /> + + + + {locations !== undefined && + !error && + !isValidating && + length > 0 && + locations.items.map(location => ( + + + + + + + + + + ))} + +
+ + + + + {location.id} + + + + + {location.short} + + + {location.long} +
+ + {locations === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/locations/NewLocationButton.tsx b/resources/scripts/components/admin/locations/NewLocationButton.tsx new file mode 100644 index 000000000..f7f688e9b --- /dev/null +++ b/resources/scripts/components/admin/locations/NewLocationButton.tsx @@ -0,0 +1,112 @@ +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useState } from 'react'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; + +import createLocation from '@/api/admin/locations/createLocation'; +import getLocations from '@/api/admin/locations/getLocations'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; + +interface Values { + short: string; + long: string; +} + +const schema = object().shape({ + short: string() + .required('A location short name must be provided.') + .max(32, 'Location short name must not exceed 32 characters.'), + long: string().max(255, 'Location long name must not exceed 255 characters.'), +}); + +export default () => { + const [visible, setVisible] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { mutate } = getLocations(); + + const submit = ({ short, long }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('location:create'); + setSubmitting(true); + + createLocation(short, long) + .then(async location => { + await mutate(data => ({ ...data!, items: data!.items.concat(location) }), false); + setVisible(false); + }) + .catch(error => { + clearAndAddHttpError({ key: 'location:create', error }); + setSubmitting(false); + }); + }; + + return ( + <> + + {({ isSubmitting, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + + +

New Location

+ +
+ + +
+ +
+ +
+ + +
+ +
+ )} +
+ + + + ); +}; diff --git a/resources/scripts/components/admin/mounts/MountDeleteButton.tsx b/resources/scripts/components/admin/mounts/MountDeleteButton.tsx new file mode 100644 index 000000000..e515e50e2 --- /dev/null +++ b/resources/scripts/components/admin/mounts/MountDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteMount from '@/api/admin/mounts/deleteMount'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + mountId: number; + onDeleted: () => void; +} + +export default ({ mountId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('mount'); + + deleteMount(mountId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'mount', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this mount? Deleting a mount will not delete files on any nodes. + + + + + ); +}; diff --git a/resources/scripts/components/admin/mounts/MountEditContainer.tsx b/resources/scripts/components/admin/mounts/MountEditContainer.tsx new file mode 100644 index 000000000..a9282cf17 --- /dev/null +++ b/resources/scripts/components/admin/mounts/MountEditContainer.tsx @@ -0,0 +1,142 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Mount } from '@/api/admin/mounts/getMounts'; +import getMount from '@/api/admin/mounts/getMount'; +import updateMount from '@/api/admin/mounts/updateMount'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import MountDeleteButton from '@/components/admin/mounts/MountDeleteButton'; +import MountForm from '@/components/admin/mounts/MountForm'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + mount: Mount | undefined; + setMount: Action; +} + +export const Context = createContextStore({ + mount: undefined, + + setMount: action((state, payload) => { + state.mount = payload; + }), +}); + +const MountEditContainer = () => { + const navigate = useNavigate(); + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const mount = Context.useStoreState(state => state.mount); + const setMount = Context.useStoreActions(actions => actions.setMount); + + const submit = ( + { name, description, source, target, readOnly, userMountable }: any, + { setSubmitting }: FormikHelpers, + ) => { + if (mount === undefined) { + return; + } + + clearFlashes('mount'); + + updateMount(mount.id, name, description, source, target, readOnly === '1', userMountable === '1') + .then(() => + setMount({ + ...mount, + name, + description, + source, + target, + readOnly: readOnly === '1', + userMountable: userMountable === '1', + }), + ) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'mount', error }); + }) + .then(() => setSubmitting(false)); + }; + + useEffect(() => { + clearFlashes('mount'); + + getMount(Number(params.id)) + .then(mount => setMount(mount)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'mount', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || mount === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{mount.name}

+ {(mount.description || '').length < 1 ? ( +

+ No description +

+ ) : ( +

+ {mount.description} +

+ )} +
+
+ + + + +
+ navigate('/admin/mounts')} /> +
+
+
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/mounts/MountForm.tsx b/resources/scripts/components/admin/mounts/MountForm.tsx new file mode 100644 index 000000000..a86b947a8 --- /dev/null +++ b/resources/scripts/components/admin/mounts/MountForm.tsx @@ -0,0 +1,133 @@ +import type { FormikHelpers } from 'formik'; +import { Field as FormikField, Form, Formik } from 'formik'; +import tw from 'twin.macro'; +import { boolean, object, string } from 'yup'; + +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Label from '@/components/elements/Label'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; + +interface Values { + name: string; + description: string; + source: string; + target: string; + readOnly: string; + userMountable: string; +} + +interface Props { + action: string; + title: string; + initialValues?: Values; + + onSubmit: (values: Values, helpers: FormikHelpers) => void; + + children?: React.ReactNode; +} + +function MountForm({ action, title, initialValues, children, onSubmit }: Props) { + const submit = (values: Values, helpers: FormikHelpers) => { + onSubmit(values, helpers); + }; + + if (!initialValues) { + initialValues = { + name: '', + description: '', + source: '', + target: '', + readOnly: '0', + userMountable: '0', + }; + } + + return ( + + {({ isSubmitting, isValid }) => ( + + + +
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ + +
+ + + +
+
+ +
+ + +
+ + + +
+
+
+ +
+ {children} + +
+ +
+
+
+
+ )} +
+ ); +} + +export default MountForm; diff --git a/resources/scripts/components/admin/mounts/MountsContainer.tsx b/resources/scripts/components/admin/mounts/MountsContainer.tsx new file mode 100644 index 000000000..bc3e0054d --- /dev/null +++ b/resources/scripts/components/admin/mounts/MountsContainer.tsx @@ -0,0 +1,241 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/mounts/getMounts'; +import getMounts, { Context as MountsContext } from '@/api/admin/mounts/getMounts'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import Button from '@/components/elements/Button'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.mounts.selectedMounts.indexOf(id) >= 0); + const appendSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.appendSelectedMount); + const removeSelectedMount = AdminContext.useStoreActions(actions => actions.mounts.removeSelectedMount); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedMount(id); + } else { + removeSelectedMount(id); + } + }} + /> + ); +}; + +const MountsContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(MountsContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: mounts, error, isValidating } = getMounts(); + + useEffect(() => { + if (!error) { + clearFlashes('mounts'); + return; + } + + clearAndAddHttpError({ key: 'mounts', error }); + }, [error]); + + const length = mounts?.items?.length || 0; + + const setSelectedMounts = AdminContext.useStoreActions(actions => actions.mounts.setSelectedMounts); + const selectedMountsLength = AdminContext.useStoreState(state => state.mounts.selectedMounts.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedMounts(e.currentTarget.checked ? mounts?.items?.map(mount => mount.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedMounts([]); + }, [page]); + + return ( + +
+
+

Mounts

+

+ Configure and manage additional mount points for servers. +

+
+ +
+ + + +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + setSort('source')} + /> + setSort('target')} + /> + + + + + + + + + + + + + + + ))} + +
+ + + + + {mounts !== undefined && + !error && + !isValidating && + length > 0 && + mounts.items.map(mount => ( + + + + + + + {mount.id} + + + + + {mount.name} + + + + + {mount.source} + + + + + + {mount.target} + + + + {mount.readOnly ? ( + + Read Only + + ) : ( + + Writable + + )} + + {mount.userMountable ? ( + + Mountable + + ) : ( + + Admin Only + + )} +
+ + {mounts === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/mounts/NewMountContainer.tsx b/resources/scripts/components/admin/mounts/NewMountContainer.tsx new file mode 100644 index 000000000..064b0942d --- /dev/null +++ b/resources/scripts/components/admin/mounts/NewMountContainer.tsx @@ -0,0 +1,51 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; + +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import MountForm from '@/components/admin/mounts/MountForm'; +import createMount from '@/api/admin/mounts/createMount'; +import type { ApplicationStore } from '@/state'; + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const submit = ( + { name, description, source, target, readOnly, userMountable }: any, + { setSubmitting }: FormikHelpers, + ) => { + clearFlashes('mount:create'); + + createMount(name, description, source, target, readOnly === '1', userMountable === '1') + .then(mount => navigate(`/admin/mounts/${mount.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'mount:create', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New Mount

+

+ Add a new mount to the panel. +

+
+
+ + + + +
+ ); +}; diff --git a/resources/scripts/components/admin/nests/ImportEggButton.tsx b/resources/scripts/components/admin/nests/ImportEggButton.tsx new file mode 100644 index 000000000..1dd6d2dd2 --- /dev/null +++ b/resources/scripts/components/admin/nests/ImportEggButton.tsx @@ -0,0 +1,82 @@ +import getEggs from '@/api/admin/nests/getEggs'; +import importEgg from '@/api/admin/nests/importEgg'; +import useFlash from '@/plugins/useFlash'; +// import { Editor } from '@/components/elements/editor'; +import { useState } from 'react'; +import Button from '@/components/elements/Button'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +export default ({ className }: { className?: string }) => { + const [visible, setVisible] = useState(false); + + const { clearFlashes } = useFlash(); + + const params = useParams<'nestId'>(); + const { mutate } = getEggs(Number(params.nestId)); + + let fetchFileContent: (() => Promise) | null = null; + + const submit = async () => { + clearFlashes('egg:import'); + + if (fetchFileContent === null) { + return; + } + + const egg = await importEgg(Number(params.nestId), await fetchFileContent()); + await mutate(data => ({ ...data!, items: [...data!.items!, egg] })); + setVisible(false); + }; + + return ( + <> + { + setVisible(false); + }} + > + + +

Import Egg

+ + {/* {*/} + {/* fetchFileContent = value;*/} + {/* }}*/} + {/*/>*/} + +
+ + +
+
+ + + + ); +}; diff --git a/resources/scripts/components/admin/nests/NestDeleteButton.tsx b/resources/scripts/components/admin/nests/NestDeleteButton.tsx new file mode 100644 index 000000000..09d573377 --- /dev/null +++ b/resources/scripts/components/admin/nests/NestDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteNest from '@/api/admin/nests/deleteNest'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + nestId: number; + onDeleted: () => void; +} + +export default ({ nestId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('nest'); + + deleteNest(nestId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'nest', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this nest? Deleting a nest will delete all eggs assigned to it. + + + + + ); +}; diff --git a/resources/scripts/components/admin/nests/NestEditContainer.tsx b/resources/scripts/components/admin/nests/NestEditContainer.tsx new file mode 100644 index 000000000..dabbead60 --- /dev/null +++ b/resources/scripts/components/admin/nests/NestEditContainer.tsx @@ -0,0 +1,250 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useEffect, useState } from 'react'; +import { NavLink, useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; + +import ImportEggButton from '@/components/admin/nests/ImportEggButton'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { Nest } from '@/api/admin/nests/getNests'; +import getNest from '@/api/admin/nests/getNest'; +import updateNest from '@/api/admin/nests/updateNest'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import AdminBox from '@/components/admin/AdminBox'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import Input from '@/components/elements/Input'; +import Label from '@/components/elements/Label'; +import NestDeleteButton from '@/components/admin/nests/NestDeleteButton'; +import NestEggTable from '@/components/admin/nests/NestEggTable'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + nest: Nest | undefined; + setNest: Action; + + selectedEggs: number[]; + + setSelectedEggs: Action; + appendSelectedEggs: Action; + removeSelectedEggs: Action; +} + +export const Context = createContextStore({ + nest: undefined, + + setNest: action((state, payload) => { + state.nest = payload; + }), + + selectedEggs: [], + + setSelectedEggs: action((state, payload) => { + state.selectedEggs = payload; + }), + + appendSelectedEggs: action((state, payload) => { + state.selectedEggs = state.selectedEggs.filter(id => id !== payload).concat(payload); + }), + + removeSelectedEggs: action((state, payload) => { + state.selectedEggs = state.selectedEggs.filter(id => id !== payload); + }), +}); + +interface Values { + name: string; + description: string; +} + +const EditInformationContainer = () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const nest = Context.useStoreState(state => state.nest); + const setNest = Context.useStoreActions(actions => actions.setNest); + + if (nest === undefined) { + return <>; + } + + const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('nest'); + + updateNest(nest.id, name, description) + .then(() => setNest({ ...nest, name, description })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'nest', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( + <> + + + +
+ + + + +
+
+ navigate('/admin/nests')} /> +
+ +
+ +
+
+ +
+ + )} +
+ ); +}; + +const ViewDetailsContainer = () => { + const nest = Context.useStoreState(state => state.nest); + + if (nest === undefined) { + return <>; + } + + return ( + +
+
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+
+
+ ); +}; + +const NestEditContainer = () => { + const params = useParams<'nestId'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const nest = Context.useStoreState(state => state.nest); + const setNest = Context.useStoreActions(actions => actions.setNest); + + useEffect(() => { + clearFlashes('nest'); + + getNest(Number(params.nestId), ['eggs']) + .then(nest => setNest(nest)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'nest', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || nest === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{nest.name}

+ {(nest.description || '').length < 1 ? ( +

+ No description +

+ ) : ( +

+ {nest.description} +

+ )} +
+ +
+ + + + + +
+
+ + + +
+ + +
+ + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nests/NestEggTable.tsx b/resources/scripts/components/admin/nests/NestEggTable.tsx new file mode 100644 index 000000000..704dec150 --- /dev/null +++ b/resources/scripts/components/admin/nests/NestEggTable.tsx @@ -0,0 +1,160 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/nests/getEggs'; +import getEggs, { Context as EggsContext } from '@/api/admin/nests/getEggs'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import { Context } from '@/components/admin/nests/NestEditContainer'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import useFlash from '@/plugins/useFlash'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = Context.useStoreState(state => state.selectedEggs.indexOf(id) >= 0); + const appendSelectedEggs = Context.useStoreActions(actions => actions.appendSelectedEggs); + const removeSelectedEggs = Context.useStoreActions(actions => actions.removeSelectedEggs); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedEggs(id); + } else { + removeSelectedEggs(id); + } + }} + /> + ); +}; + +const EggsTable = () => { + const params = useParams<'nestId' | 'id'>(); + + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(EggsContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: eggs, error, isValidating } = getEggs(Number(params.nestId)); + + useEffect(() => { + if (!error) { + clearFlashes('nests'); + return; + } + + clearAndAddHttpError({ key: 'nests', error }); + }, [error]); + + const length = eggs?.items?.length || 0; + + const setSelectedEggs = Context.useStoreActions(actions => actions.setSelectedEggs); + const selectedEggsLength = Context.useStoreState(state => state.selectedEggs.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedEggs(e.currentTarget.checked ? eggs?.items?.map(nest => nest.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedEggs([]); + }, [page]); + + return ( + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + + + + + {eggs !== undefined && + !error && + !isValidating && + length > 0 && + eggs.items.map(egg => ( + + + + + + + + + + ))} + +
+ + + + + {egg.id} + + + + + {egg.name} + + + {egg.description} +
+ + {eggs === undefined || (error && isValidating) ? : length < 1 ? : null} +
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nests/NestsContainer.tsx b/resources/scripts/components/admin/nests/NestsContainer.tsx new file mode 100644 index 000000000..2f937da80 --- /dev/null +++ b/resources/scripts/components/admin/nests/NestsContainer.tsx @@ -0,0 +1,182 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/nests/getNests'; +import getNests, { Context as NestsContext } from '@/api/admin/nests/getNests'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import NewNestButton from '@/components/admin/nests/NewNestButton'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.nests.selectedNests.indexOf(id) >= 0); + const appendSelectedNest = AdminContext.useStoreActions(actions => actions.nests.appendSelectedNest); + const removeSelectedNest = AdminContext.useStoreActions(actions => actions.nests.removeSelectedNest); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedNest(id); + } else { + removeSelectedNest(id); + } + }} + /> + ); +}; + +const NestsContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NestsContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: nests, error, isValidating } = getNests(); + + useEffect(() => { + if (!error) { + clearFlashes('nests'); + return; + } + + clearAndAddHttpError({ key: 'nests', error }); + }, [error]); + + const length = nests?.items?.length || 0; + + const setSelectedNests = AdminContext.useStoreActions(actions => actions.nests.setSelectedNests); + const selectedNestsLength = AdminContext.useStoreState(state => state.nests.selectedNests.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedNests(e.currentTarget.checked ? nests?.items?.map(nest => nest.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedNests([]); + }, [page]); + + return ( + +
+
+

Nests

+

+ All nests currently available on this system. +

+
+ +
+ +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + + + + + {nests !== undefined && + !error && + !isValidating && + length > 0 && + nests.items.map(nest => ( + + + + + + + + + + ))} + +
+ + + + + {nest.id} + + + + + {nest.name} + + + {nest.description} +
+ + {nests === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nests/NewEggContainer.tsx b/resources/scripts/components/admin/nests/NewEggContainer.tsx new file mode 100644 index 000000000..1e79eb4ee --- /dev/null +++ b/resources/scripts/components/admin/nests/NewEggContainer.tsx @@ -0,0 +1,115 @@ +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useRef } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object } from 'yup'; + +import createEgg from '@/api/admin/eggs/createEgg'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { + EggImageContainer, + EggInformationContainer, + EggLifecycleContainer, + EggProcessContainer, + EggProcessContainerRef, + EggStartupContainer, +} from '@/components/admin/nests/eggs/EggSettingsContainer'; +import Button from '@/components/elements/Button'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; + +interface Values { + name: string; + description: string; + startup: string; + dockerImages: string; + configStop: string; + configStartup: string; + configFiles: string; +} + +export default () => { + const navigate = useNavigate(); + const params = useParams<{ nestId: string }>(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const ref = useRef(); + + const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('egg:create'); + + const nestId = Number(params.nestId); + + values.configStartup = (await ref.current?.getStartupConfiguration()) || ''; + values.configFiles = (await ref.current?.getFilesConfiguration()) || ''; + + createEgg({ ...values, dockerImages: values.dockerImages.split('\n'), nestId }) + .then(egg => navigate(`/admin/nests/${nestId}/eggs/${egg.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'egg:create', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New Egg

+

+ Add a new egg to the panel. +

+
+
+ + + + + {({ isSubmitting, isValid }) => ( +
+
+ +
+ + + +
+ + +
+ + + +
+
+ +
+
+ + )} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/nests/NewNestButton.tsx b/resources/scripts/components/admin/nests/NewNestButton.tsx new file mode 100644 index 000000000..60a613840 --- /dev/null +++ b/resources/scripts/components/admin/nests/NewNestButton.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import createNest from '@/api/admin/nests/createNest'; +import getNests from '@/api/admin/nests/getNests'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { object, string } from 'yup'; +import tw from 'twin.macro'; + +interface Values { + name: string, + description: string, +} + +const schema = object().shape({ + name: string() + .required('A nest name must be provided.') + .max(32, 'Nest name must not exceed 32 characters.'), + description: string() + .max(255, 'Nest description must not exceed 255 characters.'), +}); + +export default () => { + const [ visible, setVisible ] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { mutate } = getNests(); + + const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('nest:create'); + setSubmitting(true); + + createNest(name, description) + .then(async (nest) => { + await mutate(data => ({ ...data!, items: data!.items.concat(nest) }), false); + setVisible(false); + }) + .catch(error => { + clearAndAddHttpError({ key: 'nest:create', error }); + setSubmitting(false); + }); + }; + + return ( + <> + + { + ({ isSubmitting, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + + +

New Nest

+ +
+ + +
+ +
+ +
+ + +
+ +
+ ) + } +
+ + + + ); +}; diff --git a/resources/scripts/components/admin/nests/eggs/EggDeleteButton.tsx b/resources/scripts/components/admin/nests/eggs/EggDeleteButton.tsx new file mode 100644 index 000000000..11a141693 --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteEgg from '@/api/admin/eggs/deleteEgg'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + eggId: number; + onDeleted: () => void; +} + +export default ({ eggId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('egg'); + + deleteEgg(eggId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'egg', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this egg? You may only delete an egg with no servers using it. + + + + + ); +}; diff --git a/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx b/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx new file mode 100644 index 000000000..513bc972d --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx @@ -0,0 +1,85 @@ +import { exportEgg } from '@/api/admin/egg'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +// import { jsonLanguage } from '@codemirror/lang-json'; +// import Editor from '@/components/elements/Editor'; +import { useEffect, useState } from 'react'; +import Button from '@/components/elements/Button'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +export default ({ className }: { className?: string }) => { + const params = useParams<'id'>(); + const { clearAndAddHttpError, clearFlashes } = useFlash(); + + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(true); + const [_content, setContent] = useState | null>(null); + + useEffect(() => { + if (!visible) { + return; + } + + clearFlashes('egg:export'); + setLoading(true); + + exportEgg(Number(params.id)) + .then(setContent) + .catch(error => clearAndAddHttpError({ key: 'egg:export', error })) + .then(() => setLoading(false)); + }, [visible]); + + return ( + <> + { + setVisible(false); + }} + css={tw`relative`} + > + +

Export Egg

+ + + {/**/} + +
+ + +
+
+ + + + ); +}; diff --git a/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx new file mode 100644 index 000000000..3a9626c38 --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx @@ -0,0 +1,110 @@ +import { useEggFromRoute } from '@/api/admin/egg'; +import updateEgg from '@/api/admin/eggs/updateEgg'; +import Field from '@/components/elements/Field'; +import useFlash from '@/plugins/useFlash'; +// import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { faScroll } from '@fortawesome/free-solid-svg-icons'; +import { Form, Formik, FormikHelpers } from 'formik'; +import tw from 'twin.macro'; +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; +// import Editor from '@/components/elements/Editor'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; + +interface Values { + scriptContainer: string; + scriptEntry: string; + scriptInstall: string; +} + +export default function EggInstallContainer() { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } + + let fetchFileContent: (() => Promise) | null = null; + + const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { + if (fetchFileContent === null) { + return; + } + + values.scriptInstall = await fetchFileContent(); + + clearFlashes('egg'); + + updateEgg(egg.id, values) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'egg', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( + +
+ + +
+ {/* {*/} + {/* fetchFileContent = value;*/} + {/* }}*/} + {/*/>*/} + +
+
+ + + +
+
+ +
+ +
+ +
+
+ )} +
+ ); +} diff --git a/resources/scripts/components/admin/nests/eggs/EggRouter.tsx b/resources/scripts/components/admin/nests/eggs/EggRouter.tsx new file mode 100644 index 000000000..9bf84ac3c --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggRouter.tsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { Route, Routes, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import { useEggFromRoute } from '@/api/admin/egg'; +import EggInstallContainer from '@/components/admin/nests/eggs/EggInstallContainer'; +import EggVariablesContainer from '@/components/admin/nests/eggs/EggVariablesContainer'; +import useFlash from '@/plugins/useFlash'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import EggSettingsContainer from '@/components/admin/nests/eggs/EggSettingsContainer'; + +const EggRouter = () => { + const { id, nestId } = useParams<'nestId' | 'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: egg, error, isValidating, mutate } = useEggFromRoute(); + + useEffect(() => { + mutate(); + }, []); + + useEffect(() => { + if (!error) clearFlashes('egg'); + if (error) clearAndAddHttpError({ key: 'egg', error }); + }, [error]); + + if (!egg || (error && isValidating)) { + return ( + + + + ); + } + + return ( + +
+
+

{egg.name}

+

+ {egg.uuid} +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + } /> + } /> + } /> + +
+ ); +}; + +export default () => { + return ; +}; diff --git a/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx new file mode 100644 index 000000000..314b02cba --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx @@ -0,0 +1,245 @@ +import { useEggFromRoute } from '@/api/admin/egg'; +import updateEgg from '@/api/admin/eggs/updateEgg'; +import EggDeleteButton from '@/components/admin/nests/eggs/EggDeleteButton'; +import EggExportButton from '@/components/admin/nests/eggs/EggExportButton'; +import Button from '@/components/elements/Button'; +// import Editor from '@/components/elements/Editor'; +import Field, { TextareaField } from '@/components/elements/Field'; +import Input from '@/components/elements/Input'; +import Label from '@/components/elements/Label'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +// import { jsonLanguage } from '@codemirror/lang-json'; +import { faDocker } from '@fortawesome/free-brands-svg-icons'; +import { faEgg, faFireAlt, faMicrochip, faTerminal } from '@fortawesome/free-solid-svg-icons'; +import { forwardRef, useImperativeHandle, useRef } from 'react'; +import AdminBox from '@/components/admin/AdminBox'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object } from 'yup'; +import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; + +export function EggInformationContainer() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + + + ); +} + +function EggDetailsContainer() { + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } + + return ( + +
+ + +
+ +
+ + +
+
+ ); +} + +export function EggStartupContainer({ className }: { className?: string }) { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} + +export function EggImageContainer() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} + +export function EggLifecycleContainer() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} + +interface EggProcessContainerProps { + className?: string; +} + +export interface EggProcessContainerRef { + getStartupConfiguration: () => Promise; + getFilesConfiguration: () => Promise; +} + +export const EggProcessContainer = forwardRef(function EggProcessContainer( + { className }, + ref, +) { + // const { isSubmitting, values } = useFormikContext(); + const { isSubmitting } = useFormikContext(); + + let fetchStartupConfiguration: (() => Promise) | null = null; + let fetchFilesConfiguration: (() => Promise) | null = null; + + useImperativeHandle(ref, () => ({ + getStartupConfiguration: async () => { + if (fetchStartupConfiguration === null) { + return new Promise(resolve => resolve(null)); + } + return await fetchStartupConfiguration(); + }, + + getFilesConfiguration: async () => { + if (fetchFilesConfiguration === null) { + return new Promise(resolve => resolve(null)); + } + return await fetchFilesConfiguration(); + }, + })); + + return ( + + + +
+ + {/* {*/} + {/* fetchStartupConfiguration = value;*/} + {/* }}*/} + {/*/>*/} +
+ +
+ + {/* {*/} + {/* fetchFilesConfiguration = value;*/} + {/* }}*/} + {/*/>*/} +
+
+ ); +}); + +interface Values { + name: string; + description: string; + startup: string; + dockerImages: string; + configStop: string; + configStartup: string; + configFiles: string; +} + +export default function EggSettingsContainer() { + const navigate = useNavigate(); + + const ref = useRef(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } + + const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('egg'); + + values.configStartup = (await ref.current?.getStartupConfiguration()) || ''; + values.configFiles = (await ref.current?.getFilesConfiguration()) || ''; + + updateEgg(egg.id, { + ...values, + // TODO + dockerImages: {}, + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'egg', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+
+ + +
+ + + +
+ + +
+ + + +
+
+ navigate('/admin/nests')} /> + + +
+
+ + )} +
+ ); +} diff --git a/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx new file mode 100644 index 000000000..c6cbfe925 --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx @@ -0,0 +1,218 @@ +import { TrashIcon } from '@heroicons/react/outline'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik, useFormikContext } from 'formik'; +import { useState } from 'react'; +import tw from 'twin.macro'; +import { array, boolean, object, string } from 'yup'; + +import deleteEggVariable from '@/api/admin/eggs/deleteEggVariable'; +import updateEggVariables from '@/api/admin/eggs/updateEggVariables'; +import { NoItems } from '@/components/admin/AdminTable'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { EggVariable } from '@/api/admin/egg'; +import { useEggFromRoute } from '@/api/admin/egg'; +import NewVariableButton from '@/components/admin/nests/eggs/NewVariableButton'; +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; +import Checkbox from '@/components/elements/Checkbox'; +import Field, { FieldRow, TextareaField } from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; + +export const validationSchema = object().shape({ + name: string().required().min(1).max(191), + description: string(), + environmentVariable: string().required().min(1).max(191), + defaultValue: string(), + isUserViewable: boolean().required(), + isUserEditable: boolean().required(), + rules: string().required(), +}); + +export function EggVariableForm({ prefix }: { prefix: string }) { + return ( + <> + + + + + + + + + + +
+ + + +
+ + + + ); +} + +function EggVariableDeleteButton({ onClick }: { onClick: (success: () => void) => void }) { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const onDelete = () => { + setLoading(true); + + onClick(() => { + //setLoading(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this variable? Deleting this variable will delete it from every server + using this egg. + + + + + ); +} + +function EggVariableBox({ + onDeleteClick, + variable, + prefix, +}: { + onDeleteClick: (success: () => void) => void; + variable: EggVariable; + prefix: string; +}) { + const { isSubmitting } = useFormikContext(); + + return ( + {variable.name}

} + button={} + > + + + +
+ ); +} + +export default function EggVariablesContainer() { + const { clearAndAddHttpError } = useFlash(); + + const { data: egg, mutate } = useEggFromRoute(); + + if (!egg) { + return null; + } + + const submit = (values: EggVariable[], { setSubmitting }: FormikHelpers) => { + updateEggVariables(egg.id, values) + .then(async () => await mutate()) + .catch(error => clearAndAddHttpError({ key: 'egg', error })) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+
+ {egg.relationships.variables?.length === 0 ? ( + + ) : ( +
+ {egg.relationships.variables.map((v, i) => ( + { + deleteEggVariable(egg.id, v.id) + .then(async () => { + await mutate(egg => ({ + ...egg!, + relationships: { + ...egg!.relationships, + variables: egg!.relationships.variables!.filter( + v2 => v.id === v2.id, + ), + }, + })); + success(); + }) + .catch(error => clearAndAddHttpError({ key: 'egg', error })); + }} + /> + ))} +
+ )} + +
+
+ + + +
+
+
+
+ )} +
+ ); +} diff --git a/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx b/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx new file mode 100644 index 000000000..fd9057f3c --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx @@ -0,0 +1,103 @@ +import type { FormikHelpers } from 'formik'; +import { Form, Formik, useFormikContext } from 'formik'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import type { CreateEggVariable } from '@/api/admin/eggs/createEggVariable'; +import createEggVariable from '@/api/admin/eggs/createEggVariable'; +import { useEggFromRoute } from '@/api/admin/egg'; +import { EggVariableForm, validationSchema } from '@/components/admin/nests/eggs/EggVariablesContainer'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Button from '@/components/elements/Button'; +import useFlash from '@/plugins/useFlash'; + +export default function NewVariableButton() { + const { setValues } = useFormikContext(); + const [visible, setVisible] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data: egg, mutate } = useEggFromRoute(); + + if (!egg) { + return null; + } + + const submit = (values: CreateEggVariable, { setSubmitting }: FormikHelpers) => { + clearFlashes('variable:create'); + + createEggVariable(egg.id, values) + .then(async variable => { + setValues([...egg.relationships.variables, variable]); + await mutate(egg => ({ + ...egg!, + relationships: { ...egg!.relationships, variables: [...egg!.relationships.variables, variable] }, + })); + setVisible(false); + }) + .catch(error => { + clearAndAddHttpError({ key: 'variable:create', error }); + setSubmitting(false); + }); + }; + + return ( + <> + + {({ isSubmitting, isValid, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + + +

New Variable

+ +
+ + +
+ + +
+ +
+ )} +
+ + + + ); +} diff --git a/resources/scripts/components/admin/nodes/DatabaseSelect.tsx b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx new file mode 100644 index 000000000..e032ec16c --- /dev/null +++ b/resources/scripts/components/admin/nodes/DatabaseSelect.tsx @@ -0,0 +1,56 @@ +import { useFormikContext } from 'formik'; +import { useState } from 'react'; + +import type { Database } from '@/api/admin/databases/getDatabases'; +import searchDatabases from '@/api/admin/databases/searchDatabases'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; + +export default ({ selected }: { selected: Database | null }) => { + const context = useFormikContext(); + + const [database, setDatabase] = useState(selected); + const [databases, setDatabases] = useState(null); + + const onSearch = (query: string): Promise => { + return new Promise((resolve, reject) => { + searchDatabases({ name: query }) + .then(databases => { + setDatabases(databases); + return resolve(); + }) + .catch(reject); + }); + }; + + const onSelect = (database: Database | null) => { + setDatabase(database); + context.setFieldValue('databaseHostId', database?.id || null); + }; + + const getSelectedText = (database: Database | null): string | undefined => { + return database?.name; + }; + + return ( + + {databases?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/nodes/LocationSelect.tsx b/resources/scripts/components/admin/nodes/LocationSelect.tsx new file mode 100644 index 000000000..0bf38f46e --- /dev/null +++ b/resources/scripts/components/admin/nodes/LocationSelect.tsx @@ -0,0 +1,56 @@ +import { useFormikContext } from 'formik'; +import { useState } from 'react'; + +import type { Location } from '@/api/admin/locations/getLocations'; +import searchLocations from '@/api/admin/locations/searchLocations'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; + +export default ({ selected }: { selected: Location | null }) => { + const context = useFormikContext(); + + const [location, setLocation] = useState(selected); + const [locations, setLocations] = useState(null); + + const onSearch = (query: string): Promise => { + return new Promise((resolve, reject) => { + searchLocations({ short: query }) + .then(locations => { + setLocations(locations); + return resolve(); + }) + .catch(reject); + }); + }; + + const onSelect = (location: Location | null) => { + setLocation(location); + context.setFieldValue('locationId', location?.id || null); + }; + + const getSelectedText = (location: Location | null): string | undefined => { + return location?.short; + }; + + return ( + + {locations?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/nodes/NewNodeContainer.tsx b/resources/scripts/components/admin/nodes/NewNodeContainer.tsx new file mode 100644 index 000000000..e98ba4bd8 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NewNodeContainer.tsx @@ -0,0 +1,127 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; +import { number, object, string } from 'yup'; + +import type { Values } from '@/api/admin/nodes/createNode'; +import createNode from '@/api/admin/nodes/createNode'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import NodeLimitContainer from '@/components/admin/nodes/NodeLimitContainer'; +import NodeListenContainer from '@/components/admin/nodes/NodeListenContainer'; +import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer'; +import Button from '@/components/elements/Button'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { ApplicationStore } from '@/state'; + +type Values2 = Omit, 'public'> & { behindProxy: string; public: string }; + +const initialValues: Values2 = { + name: '', + locationId: 0, + databaseHostId: null, + fqdn: '', + scheme: 'https', + behindProxy: 'false', + public: 'true', + daemonBase: '/var/lib/pterodactyl/volumes', + + listenPortHTTP: 8080, + publicPortHTTP: 8080, + listenPortSFTP: 2022, + publicPortSFTP: 2022, + + memory: 0, + memoryOverallocate: 0, + disk: 0, + diskOverallocate: 0, +}; + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const submit = (values2: Values2, { setSubmitting }: FormikHelpers) => { + clearFlashes('node:create'); + + const values: Values = { + ...values2, + behindProxy: values2.behindProxy === 'true', + public: values2.public === 'true', + }; + + createNode(values) + .then(node => navigate(`/admin/nodes/${node.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node:create', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New Node

+

+ Add a new node to the panel. +

+
+
+ + + + + {({ isSubmitting, isValid }) => ( +
+
+
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+ +
+
+
+
+
+ )} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeAboutContainer.tsx b/resources/scripts/components/admin/nodes/NodeAboutContainer.tsx new file mode 100644 index 000000000..bda7c8d77 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeAboutContainer.tsx @@ -0,0 +1,96 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; + +import type { NodeInformation } from '@/api/admin/nodes/getNodeInformation'; +import getNodeInformation from '@/api/admin/nodes/getNodeInformation'; +import AdminBox from '@/components/admin/AdminBox'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import { Context } from '@/components/admin/nodes/NodeRouter'; +import type { ApplicationStore } from '@/state'; + +const Code = ({ className, children }: { className?: string; children: ReactNode }) => { + return ( + + {children} + + ); +}; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const [loading, setLoading] = useState(true); + const [info, setInfo] = useState(null); + + const node = Context.useStoreState(state => state.node); + + if (node === undefined) { + return <>; + } + + useEffect(() => { + clearFlashes('node'); + + getNodeInformation(node.id) + .then(info => setInfo(info)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + +
Wings Version + {info?.version} +
Operating System + {info?.system.type} +
Architecture + {info?.system.arch} +
Kernel + {info?.system.release} +
CPU Threads + {info?.system.cpus} +
+ + {/* TODO: Description code-block with edit option */} +
+ ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx new file mode 100644 index 000000000..b8c47e3d4 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx @@ -0,0 +1,27 @@ +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import { useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import AllocationTable from '@/components/admin/nodes/allocations/AllocationTable'; +import CreateAllocationForm from '@/components/admin/nodes/allocations/CreateAllocationForm'; + +export default () => { + const params = useParams<'id'>(); + + return ( + <> +
+
+ +
+ +
+ + + +
+
+ + ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeConfigurationContainer.tsx b/resources/scripts/components/admin/nodes/NodeConfigurationContainer.tsx new file mode 100644 index 000000000..e8ca1d978 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeConfigurationContainer.tsx @@ -0,0 +1,70 @@ +import { faCode, faDragon } from '@fortawesome/free-solid-svg-icons'; +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; + +import getNodeConfiguration from '@/api/admin/nodes/getNodeConfiguration'; +import AdminBox from '@/components/admin/AdminBox'; +import { Context } from '@/components/admin/nodes/NodeRouter'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import type { ApplicationStore } from '@/state'; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const [configuration, setConfiguration] = useState(''); + + const node = Context.useStoreState(state => state.node); + + if (node === undefined) { + return <>; + } + + useEffect(() => { + clearFlashes('node'); + + getNodeConfiguration(node.id) + .then(configuration => setConfiguration(configuration)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node', error }); + }); + }, []); + + return ( + <> + +
+
+ + + + + +
+
+                        {configuration}
+                    
+
+
+ + + Never™ + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeDeleteButton.tsx b/resources/scripts/components/admin/nodes/NodeDeleteButton.tsx new file mode 100644 index 000000000..aafad3ef2 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteNode from '@/api/admin/nodes/deleteNode'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + nodeId: number; + onDeleted: () => void; +} + +export default ({ nodeId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('node'); + + deleteNode(nodeId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this node? + + + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeEditContainer.tsx b/resources/scripts/components/admin/nodes/NodeEditContainer.tsx new file mode 100644 index 000000000..dc7280fef --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeEditContainer.tsx @@ -0,0 +1,134 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; +import { number, object, string } from 'yup'; + +import updateNode from '@/api/admin/nodes/updateNode'; +import NodeDeleteButton from '@/components/admin/nodes/NodeDeleteButton'; +import NodeLimitContainer from '@/components/admin/nodes/NodeLimitContainer'; +import NodeListenContainer from '@/components/admin/nodes/NodeListenContainer'; +import { Context } from '@/components/admin/nodes/NodeRouter'; +import NodeSettingsContainer from '@/components/admin/nodes/NodeSettingsContainer'; +import Button from '@/components/elements/Button'; +import type { ApplicationStore } from '@/state'; + +interface Values { + name: string; + locationId: number; + databaseHostId: number | null; + fqdn: string; + scheme: string; + behindProxy: string; // Yes, this is technically a boolean. + public: string; // Yes, this is technically a boolean. + daemonBase: string; // This value cannot be updated once a node has been created. + + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + + listenPortHTTP: number; + publicPortHTTP: number; + listenPortSFTP: number; + publicPortSFTP: number; +} + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const node = Context.useStoreState(state => state.node); + const setNode = Context.useStoreActions(actions => actions.setNode); + + if (node === undefined) { + return <>; + } + + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('node'); + + const v = { ...values, behindProxy: values.behindProxy === 'true', public: values.public === 'true' }; + + updateNode(node.id, v) + .then(() => setNode({ ...node, ...v })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+
+
+ +
+ +
+
+ +
+ +
+ +
+ +
+
+ navigate('/admin/nodes')} /> + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeLimitContainer.tsx b/resources/scripts/components/admin/nodes/NodeLimitContainer.tsx new file mode 100644 index 000000000..4ef5c3a1f --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeLimitContainer.tsx @@ -0,0 +1,47 @@ +import { faMicrochip } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeListenContainer.tsx b/resources/scripts/components/admin/nodes/NodeListenContainer.tsx new file mode 100644 index 000000000..7c1baa27d --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeListenContainer.tsx @@ -0,0 +1,37 @@ +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeRouter.tsx b/resources/scripts/components/admin/nodes/NodeRouter.tsx new file mode 100644 index 000000000..a3ee6b2d9 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeRouter.tsx @@ -0,0 +1,146 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import { useEffect, useState } from 'react'; +import { Route, Routes, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Node } from '@/api/admin/nodes/getNodes'; +import getNode from '@/api/admin/nodes/getNode'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import NodeEditContainer from '@/components/admin/nodes/NodeEditContainer'; +import Spinner from '@/components/elements/Spinner'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import NodeAboutContainer from '@/components/admin/nodes/NodeAboutContainer'; +import NodeConfigurationContainer from '@/components/admin/nodes/NodeConfigurationContainer'; +import NodeAllocationContainer from '@/components/admin/nodes/NodeAllocationContainer'; +import NodeServers from '@/components/admin/nodes/NodeServers'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + node: Node | undefined; + setNode: Action; +} + +export const Context = createContextStore({ + node: undefined, + + setNode: action((state, payload) => { + state.node = payload; + }), +}); + +const NodeRouter = () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const node = Context.useStoreState(state => state.node); + const setNode = Context.useStoreActions(actions => actions.setNode); + + useEffect(() => { + clearFlashes('node'); + + getNode(Number(params.id), ['database_host', 'location']) + .then(node => setNode(node)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'node', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || node === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{node.name}

+

+ {node.uuid} +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } /> + } /> + } /> + } /> + } /> + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/NodeServers.tsx b/resources/scripts/components/admin/nodes/NodeServers.tsx new file mode 100644 index 000000000..7b11274dd --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeServers.tsx @@ -0,0 +1,10 @@ +import { Context } from '@/components/admin/nodes/NodeRouter'; +import ServersTable from '@/components/admin/servers/ServersTable'; + +function NodeServers() { + const node = Context.useStoreState(state => state.node); + + return ; +} + +export default NodeServers; diff --git a/resources/scripts/components/admin/nodes/NodeSettingsContainer.tsx b/resources/scripts/components/admin/nodes/NodeSettingsContainer.tsx new file mode 100644 index 000000000..6dfa443c5 --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodeSettingsContainer.tsx @@ -0,0 +1,95 @@ +import { faDatabase } from '@fortawesome/free-solid-svg-icons'; +import { Field as FormikField, useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import type { Node } from '@/api/admin/nodes/getNodes'; +import AdminBox from '@/components/admin/AdminBox'; +import DatabaseSelect from '@/components/admin/nodes/DatabaseSelect'; +import LocationSelect from '@/components/admin/nodes/LocationSelect'; +import Label from '@/components/elements/Label'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; + +export default function NodeSettingsContainer({ node }: { node?: Node }) { + const { isSubmitting } = useFormikContext(); + + return ( + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + +
+ + + +
+
+ +
+ + +
+ + + +
+
+ +
+ + +
+ + + +
+
+
+ ); +} diff --git a/resources/scripts/components/admin/nodes/NodesContainer.tsx b/resources/scripts/components/admin/nodes/NodesContainer.tsx new file mode 100644 index 000000000..2ad97ad7f --- /dev/null +++ b/resources/scripts/components/admin/nodes/NodesContainer.tsx @@ -0,0 +1,271 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import type { Filters } from '@/api/admin/servers/getServers'; +import getNodes, { Context as NodesContext } from '@/api/admin/nodes/getNodes'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import Button from '@/components/elements/Button'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import { bytesToString, mbToBytes } from '@/lib/formatters'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.nodes.selectedNodes.indexOf(id) >= 0); + const appendSelectedNode = AdminContext.useStoreActions(actions => actions.nodes.appendSelectedNode); + const removeSelectedNode = AdminContext.useStoreActions(actions => actions.nodes.removeSelectedNode); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedNode(id); + } else { + removeSelectedNode(id); + } + }} + /> + ); +}; + +const NodesContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(NodesContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: nodes, error, isValidating } = getNodes(['location']); + + useEffect(() => { + if (!error) { + clearFlashes('nodes'); + return; + } + + clearAndAddHttpError({ key: 'nodes', error }); + }, [error]); + + const length = nodes?.items?.length || 0; + + const setSelectedNodes = AdminContext.useStoreActions(actions => actions.nodes.setSelectedNodes); + const selectedNodesLength = AdminContext.useStoreState(state => state.nodes.selectedNodes.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedNodes(e.currentTarget.checked ? nodes?.items?.map(node => node.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedNodes([]); + }, [page]); + + return ( + +
+
+

Nodes

+

+ All nodes available on the system. +

+
+ +
+ + + +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + setSort('location_id')} + /> + setSort('fqdn')} + /> + setSort('memory')} + /> + setSort('disk')} + /> + + + + + + {nodes !== undefined && + !error && + !isValidating && + length > 0 && + nodes.items.map(node => ( + + + + + + + + {/* TODO: Have permission check for displaying location information. */} + + + + + + + + + + + + ))} + +
+ + + + + {node.id} + + + + + {node.name} + + + +
+ {node.relations.location?.short} +
+ +
+ {node.relations.location?.long} +
+
+
+ + + {node.fqdn} + + + + {bytesToString(mbToBytes(node.memory))} + + {bytesToString(mbToBytes(node.disk))} + + {node.scheme === 'https' ? ( + + Secure + + ) : ( + + Non-Secure + + )} + + {/* TODO: Change color based off of online/offline status */} + + + +
+ + {nodes === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx new file mode 100644 index 000000000..6ada3e9ec --- /dev/null +++ b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx @@ -0,0 +1,216 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/nodes/allocations/getAllocations'; +import getAllocations, { Context as AllocationsContext } from '@/api/admin/nodes/allocations/getAllocations'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + ContentWrapper, + Loading, + NoItems, + Pagination, + TableBody, + TableHead, + TableHeader, + useTableHooks, +} from '@/components/admin/AdminTable'; +import DeleteAllocationButton from '@/components/admin/nodes/allocations/DeleteAllocationButton'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import useFlash from '@/plugins/useFlash'; +import { AdminContext } from '@/state/admin'; + +function RowCheckbox({ id }: { id: number }) { + const isChecked = AdminContext.useStoreState(state => state.allocations.selectedAllocations.indexOf(id) >= 0); + const appendSelectedAllocation = AdminContext.useStoreActions( + actions => actions.allocations.appendSelectedAllocation, + ); + const removeSelectedAllocation = AdminContext.useStoreActions( + actions => actions.allocations.removeSelectedAllocation, + ); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedAllocation(id); + } else { + removeSelectedAllocation(id); + } + }} + /> + ); +} + +interface Props { + nodeId: number; + filters?: Filters; +} + +function AllocationsTable({ nodeId, filters }: Props) { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(AllocationsContext); + const { data: allocations, error, isValidating, mutate } = getAllocations(nodeId, ['server']); + + const length = allocations?.items?.length || 0; + + const setSelectedAllocations = AdminContext.useStoreActions(actions => actions.allocations.setSelectedAllocations); + const selectedAllocationLength = AdminContext.useStoreState(state => state.allocations.selectedAllocations.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedAllocations( + e.currentTarget.checked ? allocations?.items?.map?.(allocation => allocation.id) || [] : [], + ); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(filters || null); + } else { + setFilters({ ...filters, ip: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedAllocations([]); + }, [page]); + + useEffect(() => { + if (!error) { + clearFlashes('allocations'); + return; + } + + clearAndAddHttpError({ key: 'allocations', error }); + }, [error]); + + return ( + + + +
+ + + setSort('ip')} + /> + + setSort('port')} + /> + + + + + + {allocations !== undefined && + !error && + !isValidating && + length > 0 && + allocations.items.map(allocation => ( + + + + + + {allocation.alias !== null ? ( + + ) : ( + + + {allocation.relations.server !== undefined ? ( + + ) : ( + + + ))} + +
+ + + + + {allocation.ip} + + + + + + {allocation.alias} + + + + )} + + + + + {allocation.port} + + + + + {allocation.relations.server.name} + + + )} + + + { + await mutate(allocations => ({ + pagination: allocations!.pagination, + items: allocations!.items.filter( + a => a.id === allocation.id, + ), + })); + + // Go back a page if no more items will exist on the current page. + if (allocations?.items.length - (1 % 10) === 0) { + setPage(p => p - 1); + } + }} + /> +
+ + {allocations === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+ ); +} + +export default (props: Props) => { + const hooks = useTableHooks(props.filters); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx new file mode 100644 index 000000000..4c2df4ba6 --- /dev/null +++ b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx @@ -0,0 +1,118 @@ +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { array, number, object, string } from 'yup'; + +import createAllocation from '@/api/admin/nodes/allocations/createAllocation'; +import getAllocations from '@/api/admin/nodes/getAllocations'; +import getAllocations2 from '@/api/admin/nodes/allocations/getAllocations'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import type { Option } from '@/components/elements/SelectField'; +import SelectField from '@/components/elements/SelectField'; + +interface Values { + ips: string[]; + ports: number[]; + alias: string; +} + +const distinct = (value: any, index: any, self: any) => { + return self.indexOf(value) === index; +}; + +function CreateAllocationForm({ nodeId }: { nodeId: number }) { + const [ips, setIPs] = useState([]); + const [ports] = useState([]); + + const { mutate } = getAllocations2(nodeId, ['server']); + + useEffect(() => { + getAllocations(nodeId).then(allocations => { + setIPs( + allocations + .map(a => a.ip) + .filter(distinct) + .map(ip => { + return { value: ip, label: ip }; + }), + ); + }); + }, [nodeId]); + + const isValidIP = (inputValue: string): boolean => { + // TODO: Better way of checking for a valid ip (and CIDR) + return inputValue.match(/^([0-9a-f.:/]+)$/) !== null; + }; + + const isValidPort = (inputValue: string): boolean => { + // TODO: Better way of checking for a valid port (and port range) + return inputValue.match(/^([0-9-]+)$/) !== null; + }; + + const submit = ({ ips, ports, alias }: Values, { setSubmitting }: FormikHelpers) => { + setSubmitting(false); + + ips.forEach(async ip => { + const allocations = await createAllocation(nodeId, { ip, ports, alias }, ['server']); + await mutate(data => ({ ...data!, items: { ...data!.items!, ...allocations } })); + }); + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+ + + + +
+ +
+ +
+
+ +
+
+ + )} +
+ ); +} + +export default CreateAllocationForm; diff --git a/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx b/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx new file mode 100644 index 000000000..2096303fd --- /dev/null +++ b/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx @@ -0,0 +1,77 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import deleteAllocation from '@/api/admin/nodes/allocations/deleteAllocation'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + nodeId: number; + allocationId: number; + onDeleted?: () => void; +} + +export default ({ nodeId, allocationId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('allocation'); + + deleteAllocation(nodeId, allocationId) + .then(() => { + setLoading(false); + setVisible(false); + if (onDeleted !== undefined) { + onDeleted(); + } + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'allocation', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this allocation? + + + + + ); +}; diff --git a/resources/scripts/components/admin/overview/OverviewContainer.tsx b/resources/scripts/components/admin/overview/OverviewContainer.tsx new file mode 100644 index 000000000..92850a0f5 --- /dev/null +++ b/resources/scripts/components/admin/overview/OverviewContainer.tsx @@ -0,0 +1,103 @@ +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; + +import type { VersionData } from '@/api/admin/getVersion'; +import getVersion from '@/api/admin/getVersion'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Spinner from '@/components/elements/Spinner'; +import useFlash from '@/plugins/useFlash'; + +const Code = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [loading, setLoading] = useState(true); + const [versionData, setVersionData] = useState(undefined); + + useEffect(() => { + clearFlashes('overview'); + + getVersion() + .then(versionData => setVersionData(versionData)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'overview', error }); + }) + .then(() => setLoading(false)); + }, []); + + return ( + +
+
+

Overview

+

+ A quick glance at your system. +

+
+
+ + + +
+ {loading ? ( +
+ +
+ ) : ( +
+
+

+ + + + System Information +

+
+ +
+ {versionData?.panel.current === 'canary' ? ( +

+ I hope you enjoy living on the edge because you are running a{' '} + {versionData?.panel.current} version of Pterodactyl. +

+ ) : versionData?.panel.latest === versionData?.panel.current ? ( +

+ Your panel is up-to-date. The latest version + is {versionData?.panel.latest} and you are running version{' '} + {versionData?.panel.current}. +

+ ) : ( +

+ Your panel is not up-to-date. The latest + version is {versionData?.panel.latest} and you are running version{' '} + {versionData?.panel.current}. +

+ )} +
+
+ )} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/roles/NewRoleButton.tsx b/resources/scripts/components/admin/roles/NewRoleButton.tsx new file mode 100644 index 000000000..210b84492 --- /dev/null +++ b/resources/scripts/components/admin/roles/NewRoleButton.tsx @@ -0,0 +1,107 @@ +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useState } from 'react'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; + +import { getRoles, createRole } from '@/api/admin/roles'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Modal from '@/components/elements/Modal'; +import useFlash from '@/plugins/useFlash'; + +interface Values { + name: string; + description: string; +} + +const schema = object().shape({ + name: string().required('A role name must be provided.').max(32, 'Role name must not exceed 32 characters.'), + description: string().max(255, 'Role description must not exceed 255 characters.'), +}); + +export default () => { + const [visible, setVisible] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { mutate } = getRoles(); + + const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('role:create'); + setSubmitting(true); + + createRole(name, description) + .then(async role => { + await mutate(data => ({ ...data!, items: data!.items.concat(role) }), false); + setVisible(false); + }) + .catch(error => { + clearAndAddHttpError({ key: 'role:create', error }); + setSubmitting(false); + }); + }; + + return ( + <> + + {({ isSubmitting, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + +

New Role

+
+ + +
+ +
+ +
+ + +
+ +
+ )} +
+ + + + ); +}; diff --git a/resources/scripts/components/admin/roles/RoleDeleteButton.tsx b/resources/scripts/components/admin/roles/RoleDeleteButton.tsx new file mode 100644 index 000000000..e312823c8 --- /dev/null +++ b/resources/scripts/components/admin/roles/RoleDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import { deleteRole } from '@/api/admin/roles'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + roleId: number; + onDeleted: () => void; +} + +export default ({ roleId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('role'); + + deleteRole(roleId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'role', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this role? + + + + + ); +}; diff --git a/resources/scripts/components/admin/roles/RoleEditContainer.tsx b/resources/scripts/components/admin/roles/RoleEditContainer.tsx new file mode 100644 index 000000000..dc3a135d2 --- /dev/null +++ b/resources/scripts/components/admin/roles/RoleEditContainer.tsx @@ -0,0 +1,176 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; + +import { getRole, updateRole } from '@/api/admin/roles'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminBox from '@/components/admin/AdminBox'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import RoleDeleteButton from '@/components/admin/roles/RoleDeleteButton'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Spinner from '@/components/elements/Spinner'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import type { UserRole } from '@definitions/admin'; +import type { ApplicationStore } from '@/state'; + +interface ctx { + role: UserRole | undefined; + setRole: Action; +} + +export const Context = createContextStore({ + role: undefined, + + setRole: action((state, payload) => { + state.role = payload; + }), +}); + +interface Values { + name: string; + description: string; +} + +const EditInformationContainer = () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const role = Context.useStoreState(state => state.role); + const setRole = Context.useStoreActions(actions => actions.setRole); + + if (role === undefined) { + return <>; + } + + const submit = ({ name, description }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('role'); + + updateRole(role.id, name, description) + .then(() => setRole({ ...role, name, description })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'role', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( + <> + + + +
+
+ +
+ +
+ +
+ +
+
+ navigate('/admin/roles')} /> +
+ +
+ +
+
+
+
+ + )} +
+ ); +}; + +const RoleEditContainer = () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const role = Context.useStoreState(state => state.role); + const setRole = Context.useStoreActions(actions => actions.setRole); + + useEffect(() => { + clearFlashes('role'); + + getRole(Number(params.id)) + .then(role => setRole(role)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'role', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || role === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{role.name}

+ {(role.description || '').length < 1 ? ( +

+ No description +

+ ) : ( +

+ {role.description} +

+ )} +
+
+ + + + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/roles/RolesContainer.tsx b/resources/scripts/components/admin/roles/RolesContainer.tsx new file mode 100644 index 000000000..7563edd67 --- /dev/null +++ b/resources/scripts/components/admin/roles/RolesContainer.tsx @@ -0,0 +1,182 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/roles'; +import { getRoles, Context as RolesContext } from '@/api/admin/roles'; +import { AdminContext } from '@/state/admin'; +import NewRoleButton from '@/components/admin/roles/NewRoleButton'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + TableBody, + TableHead, + TableHeader, + TableRow, + Pagination, + Loading, + NoItems, + ContentWrapper, + useTableHooks, +} from '@/components/admin/AdminTable'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import useFlash from '@/plugins/useFlash'; + +const RowCheckbox = ({ id }: { id: number }) => { + const isChecked = AdminContext.useStoreState(state => state.roles.selectedRoles.indexOf(id) >= 0); + const appendSelectedRole = AdminContext.useStoreActions(actions => actions.roles.appendSelectedRole); + const removeSelectedRole = AdminContext.useStoreActions(actions => actions.roles.removeSelectedRole); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedRole(id); + } else { + removeSelectedRole(id); + } + }} + /> + ); +}; + +const RolesContainer = () => { + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(RolesContext); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: roles, error, isValidating } = getRoles(); + + useEffect(() => { + if (!error) { + clearFlashes('roles'); + return; + } + + clearAndAddHttpError({ key: 'roles', error }); + }, [error]); + + const length = roles?.items?.length || 0; + + const setSelectedRoles = AdminContext.useStoreActions(actions => actions.roles.setSelectedRoles); + const selectedRolesLength = AdminContext.useStoreState(state => state.roles.selectedRoles.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedRoles(e.currentTarget.checked ? roles?.items?.map(role => role.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(null); + } else { + setFilters({ name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedRoles([]); + }, [page]); + + return ( + +
+
+

Roles

+

+ Soon™ +

+
+ +
+ +
+
+ + + + + + +
+ + + setSort('id')} + /> + setSort('name')} + /> + + + + + {roles !== undefined && + !error && + !isValidating && + length > 0 && + roles.items.map(role => ( + + + + + + + + + + ))} + +
+ + + + + {role.id} + + + + + {role.name} + + + {role.description} +
+ + {roles === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+
+ ); +}; + +export default () => { + const hooks = useTableHooks(); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/EggSelect.tsx b/resources/scripts/components/admin/servers/EggSelect.tsx new file mode 100644 index 000000000..c6b788274 --- /dev/null +++ b/resources/scripts/components/admin/servers/EggSelect.tsx @@ -0,0 +1,75 @@ +import { useField } from 'formik'; +import type { ChangeEvent } from 'react'; +import { useEffect, useState } from 'react'; + +import type { WithRelationships } from '@/api/admin'; +import type { Egg } from '@/api/admin/egg'; +import { searchEggs } from '@/api/admin/egg'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; + +interface Props { + nestId?: number; + selectedEggId?: number; + onEggSelect: (egg: Egg | null) => void; +} + +export default ({ nestId, selectedEggId, onEggSelect }: Props) => { + const [, , { setValue, setTouched }] = useField>('environment'); + const [eggs, setEggs] = useState[] | null>(null); + + const selectEgg = (egg: Egg | null) => { + if (egg === null) { + onEggSelect(null); + return; + } + + // Clear values + setValue({}); + setTouched(true); + + onEggSelect(egg); + + const values: Record = {}; + egg.relationships.variables?.forEach(v => { + values[v.environmentVariable] = v.defaultValue; + }); + setValue(values); + setTouched(true); + }; + + useEffect(() => { + if (!nestId) { + setEggs(null); + return; + } + + searchEggs(nestId, {}) + .then(eggs => { + setEggs(eggs); + selectEgg(eggs[0] || null); + }) + .catch(error => console.error(error)); + }, [nestId]); + + const onSelectChange = (e: ChangeEvent) => { + selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null); + }; + + return ( + <> + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/NestSelector.tsx b/resources/scripts/components/admin/servers/NestSelector.tsx new file mode 100644 index 000000000..762bf39a5 --- /dev/null +++ b/resources/scripts/components/admin/servers/NestSelector.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; + +import type { Nest } from '@/api/admin/nest'; +import { searchNests } from '@/api/admin/nest'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; + +interface Props { + selectedNestId?: number; + onNestSelect: (nest: number) => void; +} + +export default ({ selectedNestId, onNestSelect }: Props) => { + const [nests, setNests] = useState(null); + + useEffect(() => { + searchNests({}) + .then(nests => { + setNests(nests); + if (selectedNestId === 0 && nests.length > 0) { + // @ts-expect-error go away + onNestSelect(nests[0].id); + } + }) + .catch(error => console.error(error)); + }, []); + + return ( + <> + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/NewServerContainer.tsx b/resources/scripts/components/admin/servers/NewServerContainer.tsx new file mode 100644 index 000000000..1970d4806 --- /dev/null +++ b/resources/scripts/components/admin/servers/NewServerContainer.tsx @@ -0,0 +1,225 @@ +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik, useFormikContext } from 'formik'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; +import { object } from 'yup'; + +import type { Egg } from '@/api/admin/egg'; +import type { CreateServerRequest } from '@/api/admin/servers/createServer'; +import createServer from '@/api/admin/servers/createServer'; +import type { Allocation, Node } from '@/api/admin/node'; +import { getAllocations } from '@/api/admin/node'; +import AdminBox from '@/components/admin/AdminBox'; +import NodeSelect from '@/components/admin/servers/NodeSelect'; +import { + ServerImageContainer, + ServerServiceContainer, + ServerVariableContainer, +} from '@/components/admin/servers/ServerStartupContainer'; +import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; +import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; +import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; + +function InternalForm() { + const { + isSubmitting, + isValid, + setFieldValue, + values: { environment }, + } = useFormikContext(); + + const [egg, setEgg] = useState(null); + const [node, setNode] = useState(null); + const [allocations, setAllocations] = useState(null); + + useEffect(() => { + if (egg === null) { + return; + } + + setFieldValue('eggId', egg.id); + setFieldValue('startup', ''); + setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : ''); + }, [egg]); + + useEffect(() => { + if (node === null) { + return; + } + + // server_id: 0 filters out assigned allocations + getAllocations(node.id, { filters: { server_id: '0' } }).then(setAllocations); + }, [node]); + + return ( +
+
+
+ + +
+ +
+
+ + +
+
+ +
+
+ + +
+ {/*
*/} + {/* /!* TODO: Multi-select *!/*/} + {/* */} + {/* */} + {/*
*/} +
+
+ + +
+ + + + + + + +
+ {/* This ensures that no variables are rendered unless the environment has a value for the variable. */} + {egg?.relationships.variables + ?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined) + .map((v, i) => ( + + ))} +
+ +
+
+ +
+
+
+
+ ); +} + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const submit = (r: CreateServerRequest, { setSubmitting }: FormikHelpers) => { + clearFlashes('server:create'); + + createServer(r) + .then(s => navigate(`/admin/servers/${s.id}`)) + .catch(error => clearAndAddHttpError({ key: 'server:create', error })) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New Server

+

+ Add a new server to the panel. +

+
+
+ + + + + + +
+ ); +}; diff --git a/resources/scripts/components/admin/servers/NodeSelect.tsx b/resources/scripts/components/admin/servers/NodeSelect.tsx new file mode 100644 index 000000000..bd1e3a675 --- /dev/null +++ b/resources/scripts/components/admin/servers/NodeSelect.tsx @@ -0,0 +1,46 @@ +import { useFormikContext } from 'formik'; +import { useState } from 'react'; + +import type { Node } from '@/api/admin/node'; +import { searchNodes } from '@/api/admin/node'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; + +export default ({ node, setNode }: { node: Node | null; setNode: (_: Node | null) => void }) => { + const { setFieldValue } = useFormikContext(); + + const [nodes, setNodes] = useState(null); + + const onSearch = async (query: string) => { + setNodes(await searchNodes({ filters: { name: query } })); + }; + + const onSelect = (node: Node | null) => { + setNode(node); + setFieldValue('nodeId', node?.id || null); + }; + + const getSelectedText = (node: Node | null): string => node?.name || ''; + + return ( + + {nodes?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/servers/OwnerSelect.tsx b/resources/scripts/components/admin/servers/OwnerSelect.tsx new file mode 100644 index 000000000..25de1133a --- /dev/null +++ b/resources/scripts/components/admin/servers/OwnerSelect.tsx @@ -0,0 +1,47 @@ +import { useFormikContext } from 'formik'; +import { useState } from 'react'; + +import { searchUserAccounts } from '@/api/admin/users'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import type { User } from '@definitions/admin'; + +export default ({ selected }: { selected?: User }) => { + const { setFieldValue } = useFormikContext(); + + const [user, setUser] = useState(selected || null); + const [users, setUsers] = useState(null); + + const onSearch = async (query: string) => { + setUsers(await searchUserAccounts({ filters: { username: query, email: query } })); + }; + + const onSelect = (user: User | null) => { + setUser(user); + setFieldValue('ownerId', user?.id || null); + }; + + const getSelectedText = (user: User | null): string => user?.email || ''; + + return ( + + {users?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/servers/ServerDeleteButton.tsx b/resources/scripts/components/admin/servers/ServerDeleteButton.tsx new file mode 100644 index 000000000..27acf2e2c --- /dev/null +++ b/resources/scripts/components/admin/servers/ServerDeleteButton.tsx @@ -0,0 +1,66 @@ +import { TrashIcon } from '@heroicons/react/outline'; +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; + +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import deleteServer from '@/api/admin/servers/deleteServer'; +import { useServerFromRoute } from '@/api/admin/server'; +import type { ApplicationStore } from '@/state'; + +export default () => { + const navigate = useNavigate(); + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + const { data: server } = useServerFromRoute(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + if (!server) return; + + setLoading(true); + clearFlashes('server'); + + deleteServer(server.id) + .then(() => navigate('/admin/servers')) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'server', error }); + + setLoading(false); + setVisible(false); + }); + }; + + if (!server) return null; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this server? + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/ServerManageContainer.tsx b/resources/scripts/components/admin/servers/ServerManageContainer.tsx new file mode 100644 index 000000000..1fda0d5de --- /dev/null +++ b/resources/scripts/components/admin/servers/ServerManageContainer.tsx @@ -0,0 +1,60 @@ +import tw from 'twin.macro'; + +import { useServerFromRoute } from '@/api/admin/server'; +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; + +export default () => { + const { data: server } = useServerFromRoute(); + + if (!server) return null; + + return ( +
+
+ +
+
+ + + +
+

Danger! This could overwrite server data.

+
+ +

+ This will reinstall the server with the assigned service scripts. +

+
+
+
+ + +

+ If you need to change the install status from uninstalled to installed, or vice versa, you may + do so with the button below. +

+
+
+
+ + +

+ This will suspend the server, stop any running processes, and immediately block the user from + being able to access their files or otherwise manage the server through the panel or API. +

+
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/ServerRouter.tsx b/resources/scripts/components/admin/servers/ServerRouter.tsx new file mode 100644 index 000000000..f21fc3795 --- /dev/null +++ b/resources/scripts/components/admin/servers/ServerRouter.tsx @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import { Route, Routes, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import ServerManageContainer from '@/components/admin/servers/ServerManageContainer'; +import ServerStartupContainer from '@/components/admin/servers/ServerStartupContainer'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer'; +import useFlash from '@/plugins/useFlash'; +import { useServerFromRoute } from '@/api/admin/server'; +import { AdjustmentsIcon, CogIcon, DatabaseIcon, FolderIcon, ShieldExclamationIcon } from '@heroicons/react/outline'; + +export default () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: server, error, isValidating, mutate } = useServerFromRoute(); + + useEffect(() => { + mutate(); + }, []); + + useEffect(() => { + if (!error) clearFlashes('server'); + if (error) clearAndAddHttpError({ key: 'server', error }); + }, [error]); + + if (!server || (error && isValidating)) { + return ( + + + + ); + } + + return ( + + +
+
+

{server.name}

+

+ {server.uuid} +

+
+
+ + + + + + + + + + + + + } /> + } /> + } /> + +
+ ); +}; diff --git a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx new file mode 100644 index 000000000..db094f884 --- /dev/null +++ b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx @@ -0,0 +1,103 @@ +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import tw from 'twin.macro'; +import { object } from 'yup'; + +import { useServerFromRoute } from '@/api/admin/server'; +import type { Values } from '@/api/admin/servers/updateServer'; +import updateServer from '@/api/admin/servers/updateServer'; +import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton'; +import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; +import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; +import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox'; +import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox'; +import Button from '@/components/elements/Button'; + +export default () => { + const { data: server } = useServerFromRoute(); + const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes); + + if (!server) return null; + + const submit = (values: Values, { setSubmitting, setFieldValue }: FormikHelpers) => { + clearFlashes('server'); + + // This value is inverted to have the switch be on when the + // OOM Killer is enabled, rather than when disabled. + values.limits.oomDisabled = !values.limits.oomDisabled; + + updateServer(server.id, values) + .then(() => { + // setServer({ ...server, ...s }); + + // TODO: Figure out how to properly clear react-selects for allocations. + setFieldValue('addAllocations', []); + setFieldValue('removeAllocations', []); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'server', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+
+
+ + + +
+
+ +
+
+ + +
+
+
+
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx new file mode 100644 index 000000000..d672c57e0 --- /dev/null +++ b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx @@ -0,0 +1,258 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik, useField, useFormikContext } from 'formik'; +import { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { object } from 'yup'; + +import type { InferModel } from '@/api/admin'; +import type { Egg, EggVariable } from '@/api/admin/egg'; +import { getEgg } from '@/api/admin/egg'; +import type { Server } from '@/api/admin/server'; +import { useServerFromRoute } from '@/api/admin/server'; +import type { Values } from '@/api/admin/servers/updateServerStartup'; +import updateServerStartup from '@/api/admin/servers/updateServerStartup'; +import EggSelect from '@/components/admin/servers/EggSelect'; +import NestSelector from '@/components/admin/servers/NestSelector'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import Button from '@/components/elements/Button'; +import Input from '@/components/elements/Input'; +import AdminBox from '@/components/admin/AdminBox'; +import Field from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import Label from '@/components/elements/Label'; +import type { ApplicationStore } from '@/state'; + +function ServerStartupLineContainer({ egg, server }: { egg: Egg | null; server: Server }) { + const { isSubmitting, setFieldValue } = useFormikContext(); + + useEffect(() => { + if (egg === null) { + return; + } + + if (server.eggId === egg.id) { + setFieldValue('image', server.container.image); + setFieldValue('startup', server.container.startup || ''); + return; + } + + // Whenever the egg is changed, set the server's startup command to the egg's default. + setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : ''); + setFieldValue('startup', ''); + }, [egg]); + + return ( + + + +
+ +
+ +
+ + +
+
+ ); +} + +export function ServerServiceContainer({ + egg, + setEgg, + nestId: _nestId, +}: { + egg: Egg | null; + setEgg: (value: Egg | null) => void; + nestId: number; +}) { + const { isSubmitting } = useFormikContext(); + + const [nestId, setNestId] = useState(_nestId); + + return ( + +
+ +
+
+ +
+
+ +
+
+ ); +} + +export function ServerImageContainer() { + const { isSubmitting } = useFormikContext(); + + return ( + + + +
+
+ +
+
+
+ ); +} + +export function ServerVariableContainer({ variable, value }: { variable: EggVariable; value?: string }) { + const key = 'environment.' + variable.environmentVariable; + + const [, , { setValue, setTouched }] = useField(key); + + const { isSubmitting } = useFormikContext(); + + useEffect(() => { + if (value === undefined) { + return; + } + + setValue(value); + setTouched(true); + }, [value]); + + return ( + {variable.name}

}> + + + +
+ ); +} + +function ServerStartupForm({ + egg, + setEgg, + server, +}: { + egg: Egg | null; + setEgg: (value: Egg | null) => void; + server: Server; +}) { + const { + isSubmitting, + isValid, + values: { environment }, + } = useFormikContext(); + + return ( +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ {/* This ensures that no variables are rendered unless the environment has a value for the variable. */} + {egg?.relationships.variables + ?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined) + .map((v, i) => ( + v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable, + )?.serverValue + } + /> + ))} +
+ +
+
+ +
+
+
+
+ ); +} + +export default () => { + const { data: server } = useServerFromRoute(); + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [egg, setEgg] = useState | null>(null); + + useEffect(() => { + if (!server) return; + + getEgg(server.eggId) + .then(egg => setEgg(egg)) + .catch(error => console.error(error)); + }, [server?.eggId]); + + if (!server) return null; + + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('server'); + + updateServerStartup(server.id, values) + // .then(s => { + // mutate(data => { ...data, ...s }); + // }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'server', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + , + image: server.container.image, + eggId: server.eggId, + skipScripts: false, + }} + validationSchema={object().shape({})} + > + + + ); +}; diff --git a/resources/scripts/components/admin/servers/ServersContainer.tsx b/resources/scripts/components/admin/servers/ServersContainer.tsx new file mode 100644 index 000000000..3533ceff8 --- /dev/null +++ b/resources/scripts/components/admin/servers/ServersContainer.tsx @@ -0,0 +1,36 @@ +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import FlashMessageRender from '@/components/FlashMessageRender'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import ServersTable from '@/components/admin/servers/ServersTable'; +import Button from '@/components/elements/Button'; + +function ServersContainer() { + return ( + +
+
+

Servers

+

+ All servers available on the system. +

+
+ +
+ + + +
+
+ + + + +
+ ); +} + +export default ServersContainer; diff --git a/resources/scripts/components/admin/servers/ServersTable.tsx b/resources/scripts/components/admin/servers/ServersTable.tsx new file mode 100644 index 000000000..c41f0b527 --- /dev/null +++ b/resources/scripts/components/admin/servers/ServersTable.tsx @@ -0,0 +1,236 @@ +import type { ChangeEvent } from 'react'; +import { useContext, useEffect } from 'react'; +import { NavLink } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { Filters } from '@/api/admin/servers/getServers'; +import getServers, { Context as ServersContext } from '@/api/admin/servers/getServers'; +import AdminCheckbox from '@/components/admin/AdminCheckbox'; +import AdminTable, { + ContentWrapper, + Loading, + NoItems, + Pagination, + TableBody, + TableHead, + TableHeader, + useTableHooks, +} from '@/components/admin/AdminTable'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import { AdminContext } from '@/state/admin'; +import useFlash from '@/plugins/useFlash'; + +function RowCheckbox({ id }: { id: number }) { + const isChecked = AdminContext.useStoreState(state => state.servers.selectedServers.indexOf(id) >= 0); + const appendSelectedServer = AdminContext.useStoreActions(actions => actions.servers.appendSelectedServer); + const removeSelectedServer = AdminContext.useStoreActions(actions => actions.servers.removeSelectedServer); + + return ( + ) => { + if (e.currentTarget.checked) { + appendSelectedServer(id); + } else { + removeSelectedServer(id); + } + }} + /> + ); +} + +interface Props { + filters?: Filters; +} + +function ServersTable({ filters }: Props) { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(ServersContext); + const { data: servers, error, isValidating } = getServers(['node', 'user']); + + const length = servers?.items?.length || 0; + + const setSelectedServers = AdminContext.useStoreActions(actions => actions.servers.setSelectedServers); + const selectedServerLength = AdminContext.useStoreState(state => state.servers.selectedServers.length); + + const onSelectAllClick = (e: ChangeEvent) => { + setSelectedServers(e.currentTarget.checked ? servers?.items?.map(server => server.id) || [] : []); + }; + + const onSearch = (query: string): Promise => { + return new Promise(resolve => { + if (query.length < 2) { + setFilters(filters || null); + } else { + setFilters({ ...filters, name: query }); + } + return resolve(); + }); + }; + + useEffect(() => { + setSelectedServers([]); + }, [page]); + + useEffect(() => { + if (!error) { + clearFlashes('servers'); + return; + } + + clearAndAddHttpError({ key: 'servers', error }); + }, [error]); + + return ( + + + +
+ + + setSort('uuidShort')} + /> + setSort('name')} + /> + setSort('owner_id')} + /> + setSort('node_id')} + /> + setSort('status')} + /> + + + + {servers !== undefined && + !error && + !isValidating && + length > 0 && + servers.items.map(server => ( + + + + + + + + {/* TODO: Have permission check for displaying user information. */} + + + {/* TODO: Have permission check for displaying node information. */} + + + + + ))} + +
+ + + + + {server.identifier} + + + + + {server.name} + + + +
+ {server.relations.user?.email} +
+ +
+ {server.relations.user?.uuid.split('-')[0]} +
+
+
+ +
+ {server.relations.node?.name} +
+ +
+ {server.relations.node?.fqdn} +
+
+
+ {server.status === 'installing' ? ( + + Installing + + ) : server.status === 'transferring' ? ( + + Transferring + + ) : server.status === 'suspended' ? ( + + Suspended + + ) : ( + + Active + + )} +
+ + {servers === undefined || (error && isValidating) ? ( + + ) : length < 1 ? ( + + ) : null} +
+
+
+
+ ); +} + +export default ({ filters }: Props) => { + const hooks = useTableHooks(filters); + + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx new file mode 100644 index 000000000..3d3b8911a --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx @@ -0,0 +1,31 @@ +import { faCogs } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import type { ReactNode } from 'react'; +import tw from 'twin.macro'; + +import { useServerFromRoute } from '@/api/admin/server'; +import AdminBox from '@/components/admin/AdminBox'; +import OwnerSelect from '@/components/admin/servers/OwnerSelect'; +import Field from '@/components/elements/Field'; + +export default ({ children }: { children?: ReactNode }) => { + const { data: server } = useServerFromRoute(); + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + + {children} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx new file mode 100644 index 000000000..46219af0a --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx @@ -0,0 +1,38 @@ +import { faConciergeBell } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Field from '@/components/elements/Field'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx new file mode 100644 index 000000000..d3adcf060 --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx @@ -0,0 +1,68 @@ +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import getAllocations from '@/api/admin/nodes/getAllocations'; +import { useServerFromRoute } from '@/api/admin/server'; +import AdminBox from '@/components/admin/AdminBox'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import type { Option } from '@/components/elements/SelectField'; +import SelectField, { AsyncSelectField } from '@/components/elements/SelectField'; + +export default () => { + const { isSubmitting } = useFormikContext(); + const { data: server } = useServerFromRoute(); + + const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => { + if (!server) { + // eslint-disable-next-line node/no-callback-literal + callback([] as Option[]); + return; + } + + const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' }); + + callback( + allocations.map(a => { + return { value: a.id.toString(), label: a.getDisplayText() }; + }), + ); + }; + + return ( + +
+
+ + +
+ + { + return { value: a.id.toString(), label: a.getDisplayText() }; + }) || [] + } + isMulti + isSearchable + /> +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx new file mode 100644 index 000000000..69312ec3e --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx @@ -0,0 +1,73 @@ +import { faBalanceScale } from '@fortawesome/free-solid-svg-icons'; +import { useFormikContext } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Field from '@/components/elements/Field'; +import FormikSwitch from '@/components/elements/FormikSwitch'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/settings/GeneralSettings.tsx b/resources/scripts/components/admin/settings/GeneralSettings.tsx new file mode 100644 index 000000000..eb4ece2c8 --- /dev/null +++ b/resources/scripts/components/admin/settings/GeneralSettings.tsx @@ -0,0 +1,37 @@ +import { Form, Formik } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Field, { FieldRow } from '@/components/elements/Field'; + +export default () => { + const submit = () => { + // + }; + + return ( + +
+
+ + + + + + + + + + + +
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/settings/MailSettings.tsx b/resources/scripts/components/admin/settings/MailSettings.tsx new file mode 100644 index 000000000..1e63eecc2 --- /dev/null +++ b/resources/scripts/components/admin/settings/MailSettings.tsx @@ -0,0 +1,102 @@ +import { Form, Formik } from 'formik'; +import tw from 'twin.macro'; + +import AdminBox from '@/components/admin/AdminBox'; +import Button from '@/components/elements/Button'; +import Field, { FieldRow } from '@/components/elements/Field'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; + +export default () => { + const submit = () => { + // + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+ + + + +
+ + +
+
+ + + + + + + + + + +
+ +
+
+ +
+
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/settings/SettingsContainer.tsx b/resources/scripts/components/admin/settings/SettingsContainer.tsx new file mode 100644 index 000000000..3fab06956 --- /dev/null +++ b/resources/scripts/components/admin/settings/SettingsContainer.tsx @@ -0,0 +1,52 @@ +import { AdjustmentsIcon, ChipIcon, CodeIcon, MailIcon, ShieldCheckIcon } from '@heroicons/react/outline'; +import { Route, Routes } from 'react-router-dom'; +import tw from 'twin.macro'; + +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import MailSettings from '@/components/admin/settings/MailSettings'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import GeneralSettings from '@/components/admin/settings/GeneralSettings'; + +export default () => { + return ( + +
+
+

Settings

+

+ Configure and manage settings for Pterodactyl. +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + } /> + } /> + Security

} /> + Features

} /> + Advanced

} /> +
+
+ ); +}; diff --git a/resources/scripts/components/admin/users/NewUserContainer.tsx b/resources/scripts/components/admin/users/NewUserContainer.tsx new file mode 100644 index 000000000..36fdf29f2 --- /dev/null +++ b/resources/scripts/components/admin/users/NewUserContainer.tsx @@ -0,0 +1,49 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import tw from 'twin.macro'; + +import type { UpdateUserValues } from '@/api/admin/users'; +import { createUser } from '@/api/admin/users'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import UserForm from '@/components/admin/users/UserForm'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { ApplicationStore } from '@/state'; + +export default () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const submit = (values: UpdateUserValues, { setSubmitting }: FormikHelpers) => { + clearFlashes('user:create'); + + createUser(values) + .then(user => navigate(`/admin/users/${user.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'user:create', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+
+

New User

+

+ Add a new user to the panel. +

+
+
+ + + + +
+ ); +}; diff --git a/resources/scripts/components/admin/users/RoleSelect.tsx b/resources/scripts/components/admin/users/RoleSelect.tsx new file mode 100644 index 000000000..11ab7aa16 --- /dev/null +++ b/resources/scripts/components/admin/users/RoleSelect.tsx @@ -0,0 +1,56 @@ +import { useFormikContext } from 'formik'; +import { useState } from 'react'; + +import { searchRoles } from '@/api/admin/roles'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import type { UserRole } from '@definitions/admin'; + +export default ({ selected }: { selected: UserRole | null }) => { + const context = useFormikContext(); + + const [role, setRole] = useState(selected); + const [roles, setRoles] = useState(null); + + const onSearch = (query: string): Promise => { + return new Promise((resolve, reject) => { + searchRoles({ name: query }) + .then(roles => { + setRoles(roles); + return resolve(); + }) + .catch(reject); + }); + }; + + const onSelect = (role: UserRole | null) => { + setRole(role); + context.setFieldValue('adminRoleId', role?.id || null); + }; + + const getSelectedText = (role: UserRole | null): string | undefined => { + return role?.name; + }; + + return ( + + {roles?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/users/UserAboutContainer.tsx b/resources/scripts/components/admin/users/UserAboutContainer.tsx new file mode 100644 index 000000000..623e6b87c --- /dev/null +++ b/resources/scripts/components/admin/users/UserAboutContainer.tsx @@ -0,0 +1,62 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { useNavigate } from 'react-router-dom'; + +import type { UpdateUserValues } from '@/api/admin/users'; +import { updateUser } from '@/api/admin/users'; +import UserDeleteButton from '@/components/admin/users/UserDeleteButton'; +import UserForm from '@/components/admin/users/UserForm'; +import { Context } from '@/components/admin/users/UserRouter'; +import type { ApplicationStore } from '@/state'; +import tw from 'twin.macro'; + +const UserAboutContainer = () => { + const navigate = useNavigate(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const user = Context.useStoreState(state => state.user); + const setUser = Context.useStoreActions(actions => actions.setUser); + + if (user === undefined) { + return <>; + } + + const submit = (values: UpdateUserValues, { setSubmitting }: FormikHelpers) => { + clearFlashes('user'); + + updateUser(user.id, values) + .then(() => setUser({ ...user, ...values })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'user', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + +
+ navigate('/admin/users')} /> +
+
+ ); +}; + +export default UserAboutContainer; diff --git a/resources/scripts/components/admin/users/UserDeleteButton.tsx b/resources/scripts/components/admin/users/UserDeleteButton.tsx new file mode 100644 index 000000000..8911a5638 --- /dev/null +++ b/resources/scripts/components/admin/users/UserDeleteButton.tsx @@ -0,0 +1,73 @@ +import type { Actions } from 'easy-peasy'; +import { useStoreActions } from 'easy-peasy'; +import { useState } from 'react'; +import tw from 'twin.macro'; + +import { deleteUser } from '@/api/admin/users'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import type { ApplicationStore } from '@/state'; + +interface Props { + userId: number; + onDeleted: () => void; +} + +export default ({ userId, onDeleted }: Props) => { + const [visible, setVisible] = useState(false); + const [loading, setLoading] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + + const onDelete = () => { + setLoading(true); + clearFlashes('user'); + + deleteUser(userId) + .then(() => { + setLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'user', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this user? + + + + + ); +}; diff --git a/resources/scripts/components/admin/users/UserForm.tsx b/resources/scripts/components/admin/users/UserForm.tsx new file mode 100644 index 000000000..1c8be4b38 --- /dev/null +++ b/resources/scripts/components/admin/users/UserForm.tsx @@ -0,0 +1,148 @@ +import type { Action } from 'easy-peasy'; +import { action, createContextStore } from 'easy-peasy'; +import type { FormikHelpers } from 'formik'; +import { Form, Formik } from 'formik'; +import tw from 'twin.macro'; +import { bool, object, string } from 'yup'; + +import type { UpdateUserValues } from '@/api/admin/users'; +import AdminBox from '@/components/admin/AdminBox'; +import RoleSelect from '@/components/admin/users/RoleSelect'; +import CopyOnClick from '@/components/elements/CopyOnClick'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import Input from '@/components/elements/Input'; +import Label from '@/components/elements/Label'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import Button from '@/components/elements/Button'; +import Field, { FieldRow } from '@/components/elements/Field'; +import type { User, UserRole } from '@definitions/admin'; + +interface ctx { + user: User | undefined; + setUser: Action; +} + +export const Context = createContextStore({ + user: undefined, + + setUser: action((state, payload) => { + state.user = payload; + }), +}); + +export interface Params { + title: string; + initialValues?: UpdateUserValues; + children?: React.ReactNode; + + onSubmit: (values: UpdateUserValues, helpers: FormikHelpers) => void; + + uuid?: string; + role: UserRole | null; +} + +export default function UserForm({ title, initialValues, children, onSubmit, uuid, role }: Params) { + const submit = (values: UpdateUserValues, helpers: FormikHelpers) => { + onSubmit(values, helpers); + }; + + if (!initialValues) { + initialValues = { + externalId: '', + username: '', + email: '', + password: '', + adminRoleId: null, + rootAdmin: false, + }; + } + + return ( + + {({ isSubmitting, isValid }) => ( + <> + + + +
+ + {uuid && ( +
+ + + + +
+ )} + + + + + +
+ + {/* TODO: Remove toggle once role permissions are implemented. */} +
+
+ +
+
+ +
+ {children} +
+ +
+
+
+
+ + )} +
+ ); +} diff --git a/resources/scripts/components/admin/users/UserRouter.tsx b/resources/scripts/components/admin/users/UserRouter.tsx new file mode 100644 index 000000000..b6f5554bf --- /dev/null +++ b/resources/scripts/components/admin/users/UserRouter.tsx @@ -0,0 +1,114 @@ +import type { Action, Actions } from 'easy-peasy'; +import { action, createContextStore, useStoreActions } from 'easy-peasy'; +import { useEffect, useState } from 'react'; +import { Route, Routes, useParams } from 'react-router-dom'; +import tw from 'twin.macro'; + +import { getUser } from '@/api/admin/users'; +import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import UserAboutContainer from '@/components/admin/users/UserAboutContainer'; +import UserServers from '@/components/admin/users/UserServers'; +import Spinner from '@/components/elements/Spinner'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import type { ApplicationStore } from '@/state'; +import type { User } from '@definitions/admin'; + +interface ctx { + user: User | undefined; + setUser: Action; +} + +export const Context = createContextStore({ + user: undefined, + + setUser: action((state, payload) => { + state.user = payload; + }), +}); + +const UserRouter = () => { + const params = useParams<'id'>(); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions( + (actions: Actions) => actions.flashes, + ); + const [loading, setLoading] = useState(true); + + const user = Context.useStoreState(state => state.user); + const setUser = Context.useStoreActions(actions => actions.setUser); + + useEffect(() => { + clearFlashes('user'); + + getUser(Number(params.id), ['role']) + .then(user => setUser(user)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'user', error }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading || user === undefined) { + return ( + + + +
+ +
+
+ ); + } + + return ( + +
+
+

{user.email}

+

+ {user.uuid} +

+
+
+ + + + + + + + + + + + + + + + + + + } /> + } /> + +
+ ); +}; + +export default () => { + return ( + + + + ); +}; diff --git a/resources/scripts/components/admin/users/UserServers.tsx b/resources/scripts/components/admin/users/UserServers.tsx new file mode 100644 index 000000000..e4d3157f4 --- /dev/null +++ b/resources/scripts/components/admin/users/UserServers.tsx @@ -0,0 +1,10 @@ +import ServersTable from '@/components/admin/servers/ServersTable'; +import { Context } from '@/components/admin/users/UserRouter'; + +function UserServers() { + const user = Context.useStoreState(state => state.user); + + return ; +} + +export default UserServers; diff --git a/resources/scripts/components/admin/users/UserTableRow.tsx b/resources/scripts/components/admin/users/UserTableRow.tsx new file mode 100644 index 000000000..ee8baf729 --- /dev/null +++ b/resources/scripts/components/admin/users/UserTableRow.tsx @@ -0,0 +1,79 @@ +import { BanIcon, DotsVerticalIcon, LockOpenIcon, PencilIcon, SupportIcon, TrashIcon } from '@heroicons/react/solid'; +import { useState } from 'react'; + +import Checkbox from '@/components/elements/inputs/Checkbox'; +import { Dropdown } from '@/components/elements/dropdown'; +import { Dialog } from '@/components/elements/dialog'; +import { Button } from '@/components/elements/button'; +import { User } from '@definitions/admin'; + +interface Props { + user: User; + selected?: boolean; + onRowChange: (user: User, selected: boolean) => void; +} + +const UserTableRow = ({ user, selected, onRowChange }: Props) => { + const [visible, setVisible] = useState(false); + + return ( + <> + setVisible(false)}> + + This account will be permanently deleted. + + setVisible(false)}>Cancel + Delete + + + + +
+ onRowChange(user, e.currentTarget.checked)} /> +
+ + +
+
+ {'User +
+
+

{user.email}

+

{user.uuid}

+
+
+ + + {user.isUsingTwoFactor && ( + + 2-FA Enabled + + )} + + + + + + + }>Edit + }>Reset Password + } disabled={!user.isUsingTwoFactor}> + Disable 2-FA + + }>Suspend + + } onClick={() => setVisible(true)} danger> + Delete Account + + + + + + ); +}; + +export default UserTableRow; diff --git a/resources/scripts/components/admin/users/UsersContainer.tsx b/resources/scripts/components/admin/users/UsersContainer.tsx new file mode 100644 index 000000000..b361722dd --- /dev/null +++ b/resources/scripts/components/admin/users/UsersContainer.tsx @@ -0,0 +1,119 @@ +import { LockOpenIcon, PlusIcon, SupportIcon, TrashIcon } from '@heroicons/react/solid'; +import { Fragment, useEffect, useState } from 'react'; + +import { useGetUsers } from '@/api/admin/users'; +import type { UUID } from '@/api/definitions'; +import { Transition } from '@/components/elements/transitions'; +import { Button } from '@/components/elements/button/index'; +import Checkbox from '@/components/elements/inputs/Checkbox'; +import InputField from '@/components/elements/inputs/InputField'; +import UserTableRow from '@/components/admin/users/UserTableRow'; +import TFootPaginated from '@/components/elements/table/TFootPaginated'; +import type { User } from '@definitions/admin'; +import extractSearchFilters from '@/helpers/extractSearchFilters'; +import useDebouncedState from '@/plugins/useDebouncedState'; + +const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const; + +const UsersContainer = () => { + const [search, setSearch] = useDebouncedState('', 500); + const [selected, setSelected] = useState([]); + const { data: users } = useGetUsers( + extractSearchFilters(search, filters, { + splitUnmatched: true, + returnUnmatched: true, + }), + ); + + useEffect(() => { + document.title = 'Admin | Users'; + }, []); + + const onRowChange = (user: User, checked: boolean) => { + setSelected(state => { + return checked ? [...state, user.uuid] : selected.filter(uuid => uuid !== user.uuid); + }); + }; + + const selectAllChecked = users && users.items.length > 0 && selected.length > 0; + const onSelectAll = () => + setSelected(state => (state.length > 0 ? [] : users?.items.map(({ uuid }) => uuid) || [])); + + return ( +
+
+ +
+
+
+ +
+
+ setSearch(e.currentTarget.value)} + /> +
+ 0} duration={'duration-75'}> +
+
+ +
+ + + + + + + + + +
+
+
+ + + + + + + + {users?.items.map(user => ( + + ))} + + {users && } +
+ + Email + + +
+
+ ); +}; + +export default UsersContainer; diff --git a/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx b/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx index aaf8ccd64..bc5995284 100644 --- a/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx +++ b/resources/scripts/components/dashboard/activity/ActivityLogContainer.tsx @@ -47,7 +47,7 @@ export default () => { {!data && isValidating ? ( ) : ( -
+
{data?.items.map(activity => ( {typeof activity.properties.useragent === 'string' && ( diff --git a/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx b/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx index cddd5d55a..13208670a 100644 --- a/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx +++ b/resources/scripts/components/dashboard/forms/RecoveryTokensDialog.tsx @@ -28,13 +28,13 @@ export default ({ tokens, open, onClose }: RecoveryTokenDialogProps) => { > -
+                
                     {grouped.map(value => (
                         
                             {value[0]}
-                             
+                             
                             {value[1]}
-                             
+                             
                         
                     ))}
                 
diff --git a/resources/scripts/components/dashboard/forms/SetupTOTPDialog.tsx b/resources/scripts/components/dashboard/forms/SetupTOTPDialog.tsx index f10b1036d..5f5616d5e 100644 --- a/resources/scripts/components/dashboard/forms/SetupTOTPDialog.tsx +++ b/resources/scripts/components/dashboard/forms/SetupTOTPDialog.tsx @@ -62,7 +62,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => { return (
-
+
{!token ? ( ) : ( @@ -70,7 +70,7 @@ const ConfigureTwoFactorForm = ({ onTokens }: Props) => { )}
-

+

{token?.secret.match(/.{1,4}/g)!.join(' ') || 'Loading...'}

diff --git a/resources/scripts/components/elements/Code.tsx b/resources/scripts/components/elements/Code.tsx index 02640d699..90c6c7835 100644 --- a/resources/scripts/components/elements/Code.tsx +++ b/resources/scripts/components/elements/Code.tsx @@ -11,7 +11,7 @@ export default ({ dark, className, children }: CodeProps) => ( {children} diff --git a/resources/scripts/components/elements/CopyOnClick.tsx b/resources/scripts/components/elements/CopyOnClick.tsx index 80c01277c..50ed4fcb3 100644 --- a/resources/scripts/components/elements/CopyOnClick.tsx +++ b/resources/scripts/components/elements/CopyOnClick.tsx @@ -51,7 +51,7 @@ const CopyOnClick = ({ text, showInNotification = true, children }: CopyOnClickP
-
+

{showInNotification ? `Copied "${String(text)}" to clipboard.` diff --git a/resources/scripts/components/elements/Editor.tsx b/resources/scripts/components/elements/Editor.tsx new file mode 100644 index 000000000..c3a77b9c6 --- /dev/null +++ b/resources/scripts/components/elements/Editor.tsx @@ -0,0 +1,318 @@ +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets'; +import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { commentKeymap } from '@codemirror/comment'; +import { foldGutter, foldKeymap } from '@codemirror/fold'; +import { lineNumbers, highlightActiveLineGutter } from '@codemirror/gutter'; +import { defaultHighlightStyle } from '@codemirror/highlight'; +import { history, historyKeymap } from '@codemirror/history'; +import { indentOnInput, LanguageSupport, LRLanguage, indentUnit } from '@codemirror/language'; +import { lintKeymap } from '@codemirror/lint'; +import { bracketMatching } from '@codemirror/matchbrackets'; +import { rectangularSelection } from '@codemirror/rectangular-selection'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { Compartment, Extension, EditorState } from '@codemirror/state'; +import { StreamLanguage, StreamParser } from '@codemirror/stream-parser'; +import { keymap, highlightSpecialChars, drawSelection, highlightActiveLine, EditorView } from '@codemirror/view'; +import { clike } from '@codemirror/legacy-modes/mode/clike'; +import { cpp } from '@codemirror/lang-cpp'; +import { css } from '@codemirror/lang-css'; +import { Cassandra, MariaSQL, MSSQL, MySQL, PostgreSQL, sql, SQLite, StandardSQL } from '@codemirror/lang-sql'; +import { diff } from '@codemirror/legacy-modes/mode/diff'; +import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; +import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; +import { go } from '@codemirror/legacy-modes/mode/go'; +import { html } from '@codemirror/lang-html'; +import { http } from '@codemirror/legacy-modes/mode/http'; +import { javascript, typescriptLanguage } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; +import { lua } from '@codemirror/legacy-modes/mode/lua'; +import { properties } from '@codemirror/legacy-modes/mode/properties'; +import { python } from '@codemirror/legacy-modes/mode/python'; +import { ruby } from '@codemirror/legacy-modes/mode/ruby'; +import { rust } from '@codemirror/lang-rust'; +import { shell } from '@codemirror/legacy-modes/mode/shell'; +import { toml } from '@codemirror/legacy-modes/mode/toml'; +import { xml } from '@codemirror/lang-xml'; +import { yaml } from '@codemirror/legacy-modes/mode/yaml'; +import React, { useCallback, useEffect, useState } from 'react'; +import tw, { styled, TwStyle } from 'twin.macro'; +import { ayuMirage } from '@/components/elements/EditorTheme'; + +type EditorMode = LanguageSupport | LRLanguage | StreamParser; + +export interface Mode { + name: string; + mime: string; + mimes?: string[]; + mode?: EditorMode; + ext?: string[]; + alias?: string[]; + file?: RegExp; +} + +export const modes: Mode[] = [ + { name: 'C', mime: 'text/x-csrc', mode: clike({}), ext: [ 'c', 'h', 'ino' ] }, + { name: 'C++', mime: 'text/x-c++src', mode: cpp(), ext: [ 'cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx' ], alias: [ 'cpp' ] }, + { name: 'C#', mime: 'text/x-csharp', mode: clike({}), ext: [ 'cs' ], alias: [ 'csharp', 'cs' ] }, + { name: 'CSS', mime: 'text/css', mode: css(), ext: [ 'css' ] }, + { name: 'CQL', mime: 'text/x-cassandra', mode: sql({ dialect: Cassandra }), ext: [ 'cql' ] }, + { name: 'Diff', mime: 'text/x-diff', mode: diff, ext: [ 'diff', 'patch' ] }, + { name: 'Dockerfile', mime: 'text/x-dockerfile', mode: dockerFile, file: /^Dockerfile$/ }, + { name: 'Git Markdown', mime: 'text/x-gfm', mode: markdown({ defaultCodeLanguage: markdownLanguage }), file: /^(readme|contributing|history|license).md$/i }, + { name: 'Golang', mime: 'text/x-go', mode: go, ext: [ 'go' ] }, + { name: 'HTML', mime: 'text/html', mode: html(), ext: [ 'html', 'htm', 'handlebars', 'hbs' ], alias: [ 'xhtml' ] }, + { name: 'HTTP', mime: 'message/http', mode: http }, + { name: 'JavaScript', mime: 'text/javascript', mimes: [ 'text/javascript', 'text/ecmascript', 'application/javascript', 'application/x-javascript', 'application/ecmascript' ], mode: javascript(), ext: [ 'js' ], alias: [ 'ecmascript', 'js', 'node' ] }, + { name: 'JSON', mime: 'application/json', mimes: [ 'application/json', 'application/x-json' ], mode: json(), ext: [ 'json', 'json5', 'map' ], alias: [ 'json5' ] }, + { name: 'Lua', mime: 'text/x-lua', mode: lua, ext: [ 'lua' ] }, + { name: 'Markdown', mime: 'text/x-markdown', mode: markdown({ defaultCodeLanguage: markdownLanguage }), ext: [ 'markdown', 'md', 'mkd' ] }, + { name: 'MariaDB', mime: 'text/x-mariadb', mode: sql({ dialect: MariaSQL }) }, + { name: 'MS SQL', mime: 'text/x-mssql', mode: sql({ dialect: MSSQL }) }, + { name: 'MySQL', mime: 'text/x-mysql', mode: sql({ dialect: MySQL }) }, + { name: 'Plain Text', mime: 'text/plain', mode: undefined, ext: [ 'txt', 'text', 'conf', 'def', 'list', 'log' ] }, + { name: 'PostgreSQL', mime: 'text/x-pgsql', mode: sql({ dialect: PostgreSQL }) }, + { name: 'Properties', mime: 'text/x-properties', mode: properties, ext: [ 'properties', 'ini', 'in' ], alias: [ 'ini', 'properties' ] }, + { name: 'Python', mime: 'text/x-python', mode: python, ext: [ 'BUILD', 'bzl', 'py', 'pyw' ], file: /^(BUCK|BUILD)$/ }, + { name: 'Ruby', mime: 'text/x-ruby', mode: ruby, ext: [ 'rb' ], alias: [ 'jruby', 'macruby', 'rake', 'rb', 'rbx' ] }, + { name: 'Rust', mime: 'text/x-rustsrc', mode: rust(), ext: [ 'rs' ] }, + { name: 'Sass', mime: 'text/x-sass', mode: css(), ext: [ 'sass' ] }, + { name: 'SCSS', mime: 'text/x-scss', mode: css(), ext: [ 'scss' ] }, + { name: 'Shell', mime: 'text/x-sh', mimes: [ 'text/x-sh', 'application/x-sh' ], mode: shell, ext: [ 'sh', 'ksh', 'bash' ], alias: [ 'bash', 'sh', 'zsh' ], file: /^PKGBUILD$/ }, + { name: 'SQL', mime: 'text/x-sql', mode: sql({ dialect: StandardSQL }), ext: [ 'sql' ] }, + { name: 'SQLite', mime: 'text/x-sqlite', mode: sql({ dialect: SQLite }) }, + { name: 'TOML', mime: 'text/x-toml', mode: toml, ext: [ 'toml' ] }, + { name: 'TypeScript', mime: 'application/typescript', mode: typescriptLanguage, ext: [ 'ts' ], alias: [ 'ts' ] }, + { name: 'XML', mime: 'application/xml', mimes: [ 'application/xml', 'text/xml' ], mode: xml(), ext: [ 'xml', 'xsl', 'xsd', 'svg' ], alias: [ 'rss', 'wsdl', 'xsd' ] }, + { name: 'YAML', mime: 'text/x-yaml', mimes: [ 'text/x-yaml', 'text/yaml' ], mode: yaml, ext: [ 'yaml', 'yml' ], alias: [ 'yml' ] }, +]; + +export const modeToExtension = (m: EditorMode): Extension => { + if (m instanceof LanguageSupport) { + return m; + } + + if (m instanceof LRLanguage) { + return m; + } + + return StreamLanguage.define(m); +}; + +const findModeByFilename = (filename: string): Mode => { + for (let i = 0; i < modes.length; i++) { + const info = modes[i]; + + if (info.file && info.file.test(filename)) { + return info; + } + } + + const dot = filename.lastIndexOf('.'); + const ext = dot > -1 && filename.substring(dot + 1, filename.length); + + if (ext) { + for (let i = 0; i < modes.length; i++) { + const info = modes[i]; + if (info.ext) { + for (let j = 0; j < info.ext.length; j++) { + if (info.ext[j] === ext) { + return info; + } + } + } + } + } + + const plainText = modes.find(m => m.mime === 'text/plain'); + if (plainText === undefined) { + throw new Error('failed to find \'text/plain\' mode'); + } + return plainText; +}; + +const findLanguageExtensionByMode = (mode: Mode): Extension => { + if (mode.mode === undefined) { + return []; + } + return modeToExtension(mode.mode); +}; + +const defaultExtensions: Extension = [ + ayuMirage, + + lineNumbers(), + highlightActiveLineGutter(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + defaultHighlightStyle.fallback, + bracketMatching(), + closeBrackets(), + autocompletion(), + rectangularSelection(), + highlightActiveLine(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...commentKeymap, + ...completionKeymap, + ...lintKeymap, + indentWithTab, + ]), + EditorState.tabSize.of(4), + // This is gonna piss people off, but that isn't my problem. + indentUnit.of('\t'), +]; + +const EditorContainer = styled.div<{ overrides?: TwStyle }>` + //min-height: 12rem; + ${tw`relative`}; + + & > div { + ${props => props.overrides}; + + &.cm-focused { + outline: none; + } + } +`; + +export interface Props { + className?: string; + style?: React.CSSProperties; + overrides?: TwStyle; + + initialContent?: string; + extensions?: Extension[]; + mode?: EditorMode; + + filename?: string; + onModeChanged?: (mode: Mode) => void; + fetchContent?: (callback: () => Promise) => void; + onContentSaved?: () => void; +} + +export default ({ className, style, overrides, initialContent, extensions, mode, filename, onModeChanged, fetchContent, onContentSaved }: Props) => { + const [ languageConfig ] = useState(new Compartment()); + const [ keybinds ] = useState(new Compartment()); + const [ view, setView ] = useState(); + + const createEditorState = () => { + return EditorState.create({ + doc: initialContent, + extensions: [ + ...defaultExtensions, + ...(extensions !== undefined ? extensions : []), + + languageConfig.of(mode !== undefined ? modeToExtension(mode) : findLanguageExtensionByMode(findModeByFilename(filename || ''))), + keybinds.of([]), + ], + }); + }; + + const ref = useCallback((node) => { + if (!node) { + return; + } + + const view = new EditorView({ + state: createEditorState(), + parent: node, + }); + setView(view); + }, []); + + // This useEffect is required to send the proper mode back to the parent element + // due to the initial language being set with EditorState#create, rather than in + // an useEffect like this one, or one watching `filename`. + useEffect(() => { + if (onModeChanged === undefined) { + return; + } + + onModeChanged(findModeByFilename(filename || '')); + }, []); + + useEffect(() => { + if (view === undefined) { + return; + } + + if (mode === undefined) { + return; + } + + view.dispatch({ + effects: languageConfig.reconfigure(modeToExtension(mode)), + }); + }, [ mode ]); + + useEffect(() => { + if (view === undefined) { + return; + } + + if (filename === undefined) { + return; + } + + const mode = findModeByFilename(filename || ''); + + view.dispatch({ + effects: languageConfig.reconfigure(findLanguageExtensionByMode(mode)), + }); + + if (onModeChanged !== undefined) { + onModeChanged(mode); + } + }, [ filename ]); + + useEffect(() => { + if (view === undefined) { + return; + } + + // We could dispatch a view update to replace the content, but this would keep the edit history, + // and previously would duplicate the content of the editor. + view.setState(createEditorState()); + }, [ initialContent ]); + + useEffect(() => { + if (fetchContent === undefined) { + return; + } + + if (!view) { + fetchContent(() => Promise.reject(new Error('no editor session has been configured'))); + return; + } + + if (onContentSaved !== undefined) { + view.dispatch({ + effects: keybinds.reconfigure(keymap.of([ + { + key: 'Mod-s', + run: () => { + onContentSaved(); + return true; + }, + }, + ])), + }); + } + + fetchContent(() => Promise.resolve(view.state.doc.toString())); + }, [ view, fetchContent, onContentSaved ]); + + return ( + + ); +}; diff --git a/resources/scripts/components/elements/Field.tsx b/resources/scripts/components/elements/Field.tsx index 3ce78349a..907689cb7 100644 --- a/resources/scripts/components/elements/Field.tsx +++ b/resources/scripts/components/elements/Field.tsx @@ -1,8 +1,12 @@ +import type { FieldProps } from 'formik'; +import { Field as FormikField } from 'formik'; +import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'; import { forwardRef } from 'react'; -import * as React from 'react'; -import { Field as FormikField, FieldProps } from 'formik'; -import Input from '@/components/elements/Input'; +import tw, { styled } from 'twin.macro'; + import Label from '@/components/elements/Label'; +import Input, { Textarea } from '@/components/elements/Input'; +import InputError from '@/components/elements/InputError'; interface OwnProps { name: string; @@ -12,7 +16,7 @@ interface OwnProps { validate?: (value: any) => undefined | string | Promise; } -type Props = OwnProps & Omit, 'name'>; +type Props = OwnProps & Omit, 'name'>; const Field = forwardRef( ({ id, name, light = false, label, description, validate, ...props }, ref) => ( @@ -47,3 +51,42 @@ const Field = forwardRef( Field.displayName = 'Field'; export default Field; + +type TextareaProps = OwnProps & Omit, 'name'>; + +export const TextareaField = forwardRef(function TextareaField( + { id, name, light = false, label, description, validate, className, ...props }, + ref, +) { + return ( + + {({ field, form: { errors, touched } }: FieldProps) => ( +

+ {label && ( + + )} +