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 : (
+
+
+ setPage(pagination.currentPage - 1)}
+ >
+
+
+
+
+
+ {pages.map(page => (
+ setPage(page)}
+ active={pagination.currentPage === page}
+ >
+ {page}
+
+ ))}
+
+ setPage(pagination.currentPage + 1)}
+ >
+
+
+
+
+
+
+ )}
+
+ >
+ );
+}
+
+export const Loading = () => {
+ return (
+
+
+
+ );
+};
+
+export const NoItems = ({ className }: { className?: string }) => {
+ return (
+
+
+
+
+
+
+ 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 (
+
+ );
+};
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.
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+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.
+
+
+
+
+
+
+ New Database Host
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+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
+
+
+
+ )}
+
+
+ setVisible(true)}
+ >
+ 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.
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+
+
+
+
+
+ )}
+
+ );
+}
+
+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.
+
+
+
+
+
+
+ New Mount
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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;*/}
+ {/* }}*/}
+ {/*/>*/}
+
+
+ setVisible(false)}
+ isSecondary
+ >
+ Cancel
+
+
+ Import Egg
+
+
+
+
+ setVisible(true)}
+ isSecondary
+ >
+ Import
+
+ >
+ );
+};
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.
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+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}
+
+ )}
+
+
+
+
+
+
+
+ New Egg
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+ )
+ }
+
+
+ setVisible(true)}>
+ 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.
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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
+
+
+ {/* */}
+
+
+ setVisible(false)}
+ isSecondary
+ >
+ Close
+
+
+ Save
+
+
+
+
+ setVisible(true)}
+ isSecondary
+ >
+ Export
+
+ >
+ );
+};
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 }) => (
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+ UUID
+
+
+
+
+ Author
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+ Startup Configuration
+ {/* {*/}
+ {/* fetchStartupConfiguration = value;*/}
+ {/* }}*/}
+ {/*/>*/}
+
+
+
+ Configuration Files
+ {/* {*/}
+ {/* 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 }) => (
+
+ )}
+
+ );
+}
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.
+
+
+ setVisible(true)}
+ >
+
+
+ >
+ );
+}
+
+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 }) => (
+
+ )}
+
+ );
+}
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
+
+
+
+ )}
+
+
+ setVisible(true)}>
+ 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 => (
+
+ {d.name}
+
+ ))}
+
+ );
+};
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 => (
+
+ {d.short}
+
+ ))}
+
+ );
+};
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?
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+
+ )}
+
+ );
+};
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SSL
+
+
+
+
+ Enabled
+
+
+
+
+ Disabled
+
+
+
+
+
+
Behind Proxy
+
+
+
+
+ No
+
+
+
+
+ Yes
+
+
+
+
+
+
Automatic Allocation
+
+
+
+
+ Disabled
+
+
+
+
+ Enabled
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+
+ New Node
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSort('id')}
+ />
+ setSort('name')}
+ />
+ setSort('location_id')}
+ />
+ setSort('fqdn')}
+ />
+ setSort('memory')}
+ />
+ setSort('disk')}
+ />
+
+
+
+
+
+ {nodes !== undefined &&
+ !error &&
+ !isValidating &&
+ length > 0 &&
+ nodes.items.map(node => (
+
+
+
+
+
+
+
+
+ {node.id}
+
+
+
+
+
+
+ {node.name}
+
+
+
+ {/* TODO: Have permission check for displaying location information. */}
+
+
+
+ {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.ip}
+
+
+
+
+ {allocation.alias !== null ? (
+
+
+
+ {allocation.alias}
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {allocation.port}
+
+
+
+
+ {allocation.relations.server !== undefined ? (
+
+
+ {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?
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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
+
+
+ )}
+
+
+ setVisible(true)}
+ >
+ 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?
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+};
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
+ 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 (
+ <>
+ Egg
+
+ {!eggs ? (
+ Loading...
+ ) : (
+ eggs.map(v => (
+
+ {v.name}
+
+ ))
+ )}
+
+ >
+ );
+};
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 (
+ <>
+ Nest
+ onNestSelect(Number(e.currentTarget.value))}>
+ {!nests ? (
+ Loading...
+ ) : (
+ nests?.map(v => (
+
+ {v.name}
+
+ ))
+ )}
+
+ >
+ );
+};
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 (
+
+ );
+}
+
+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 => (
+
+ {d.name}
+
+ ))}
+
+ );
+};
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 => (
+
+ {d.email}
+
+ ))}
+
+ );
+};
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?
+
+ setVisible(true)}
+ css={tw`flex items-center justify-center`}
+ >
+ Delete 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.
+
+
+ Reinstall Server
+
+
+ This will reinstall the server with the assigned service scripts.
+
+
+
+
+
+
+ Set Server as Installing
+
+
+ If you need to change the install status from uninstalled to installed, or vice versa, you may
+ do so with the button below.
+
+
+
+
+
+
+ Suspend Server
+
+
+ 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 (
+
+
+
+
+
+
+
+
+ Default Startup Command
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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.
+
+
+
+
+
+
+ New Server
+
+
+
+
+
+
+
+
+
+ );
+}
+
+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 => (
+
+
+
+
+
+
+
+
+ {server.identifier}
+
+
+
+
+
+
+ {server.name}
+
+
+
+ {/* TODO: Have permission check for displaying user information. */}
+
+
+
+ {server.relations.user?.email}
+
+
+
+ {server.relations.user?.uuid.split('-')[0]}
+
+
+
+
+ {/* TODO: Have permission check for displaying node information. */}
+
+
+
+ {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 (
+
+
+
+ Primary Allocation
+
+ {server?.relationships.allocations?.map(a => (
+
+ {a.getDisplayText()}
+
+ ))}
+
+
+
+
{
+ 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 => (
+
+ {d.name}
+
+ ))}
+
+ );
+};
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?
+
+
+ setVisible(true)}>
+
+
+
+
+ >
+ );
+};
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 }) => (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ );
+}
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.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'}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Email
+
+
+
+
+
+
+ {users?.items.map(user => (
+
+ ))}
+
+ {users && }
+
+
+ );
+};
+
+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 (