commit
f54d4f965c
130 changed files with 1906 additions and 815 deletions
|
@ -25,7 +25,7 @@ RUN cp docker/default.conf /etc/nginx/conf.d/default.conf \
|
|||
&& cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& rm /usr/local/etc/php-fpm.d/www.conf.default \
|
||||
&& cat docker/supervisord.conf > /etc/supervisord.conf \
|
||||
&& echo "* * * * * /usr/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
|
||||
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
|
||||
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
|
||||
&& mkdir -p /var/run/php /var/run/nginx
|
||||
|
||||
|
|
|
@ -32,6 +32,12 @@ in becoming a sponsor?](https://github.com/sponsors/DaneEveritt)
|
|||
> DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance
|
||||
> and giving you the best performance money can buy.
|
||||
|
||||
#### [Skynode](https://www.skynode.pro/)
|
||||
> Skynode provides blazing fast game servers along with a top notch user experience. Whatever our clients are looking
|
||||
> for, we're able to provide it!
|
||||
|
||||
#### [XCORE-SERVER.de](https://xcore-server.de)
|
||||
> XCORE-SERVER.de offers High-End Servers for hosting and gaming since 2012. Fast, excellent and well known for eSports Gaming.
|
||||
|
||||
## Support & Documentation
|
||||
Support for using Pterodactyl can be found on our [Documentation Website](https://pterodactyl.io/project/introduction.html), [Guides Website](https://pterodactyl.io/community/about.html), or via our [Discord Chat](https://discord.gg/QRDZvVm).
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Console\Commands\Maintenance;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use InvalidArgumentException;
|
||||
use Illuminate\Console\Command;
|
||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
|
||||
class PruneOrphanedBackupsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'p:maintenance:prune-backups {--since-minutes=30}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Marks all backups that have not completed in the last "n" minutes as being failed.';
|
||||
|
||||
/**
|
||||
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
|
||||
*/
|
||||
public function handle(BackupRepository $repository)
|
||||
{
|
||||
$since = $this->option('since-minutes');
|
||||
if (! is_digit($since)) {
|
||||
throw new InvalidArgumentException('The --since-minutes option must be a valid numeric digit.');
|
||||
}
|
||||
|
||||
$query = $repository->getBuilder()
|
||||
->whereNull('completed_at')
|
||||
->whereDate('created_at', '<=', CarbonImmutable::now()->subMinutes($since));
|
||||
|
||||
$count = $query->count();
|
||||
if (! $count) {
|
||||
$this->info('There are no orphaned backups to be marked as failed.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->warn("Marking {$count} backups that have not been marked as completed in the last {$since} minutes as failed.");
|
||||
|
||||
$query->update([
|
||||
'is_successful' => false,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
'updated_at' => CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -22,7 +22,16 @@ class Kernel extends ConsoleKernel
|
|||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
|
||||
$schedule->command('p:schedule:process')->everyMinute()->withoutOverlapping();
|
||||
|
||||
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be removed
|
||||
// from the UI view for the server.
|
||||
$schedule->command('p:maintenance:prune-backups', [
|
||||
'--since-minutes' => '30',
|
||||
])->everyThirtyMinutes();
|
||||
|
||||
// Every day cleanup any internal backups of service files.
|
||||
$schedule->command('p:maintenance:clean-service-backups')->daily();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,18 +65,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter
|
|||
*/
|
||||
public function getPrimaryAllocation(Server $server, bool $refresh = false): Server;
|
||||
|
||||
/**
|
||||
* Return all of the server variables possible and default to the variable
|
||||
* default if there is no value defined for the specific server requested.
|
||||
*
|
||||
* @param int $id
|
||||
* @param bool $returnAsObject
|
||||
* @return array|object
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function getVariablesWithValues(int $id, bool $returnAsObject = false);
|
||||
|
||||
/**
|
||||
* Return enough data to be used for the creation of a server via the daemon.
|
||||
*
|
||||
|
|
|
@ -178,16 +178,21 @@ class Handler extends ExceptionHandler
|
|||
return [str_replace('.', '_', $field) => $cleaned];
|
||||
})->toArray();
|
||||
|
||||
$errors = collect($exception->errors())->map(function ($errors, $field) use ($codes) {
|
||||
$errors = collect($exception->errors())->map(function ($errors, $field) use ($codes, $exception) {
|
||||
$response = [];
|
||||
foreach ($errors as $key => $error) {
|
||||
$response[] = [
|
||||
'code' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get(
|
||||
$meta = [
|
||||
'source_field' => $field,
|
||||
'rule' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get(
|
||||
$codes, str_replace('.', '_', $field) . '.' . $key
|
||||
)),
|
||||
'detail' => $error,
|
||||
'source' => ['field' => $field],
|
||||
];
|
||||
|
||||
$converted = self::convertToArray($exception)['errors'][0];
|
||||
$converted['detail'] = $error;
|
||||
$converted['meta'] = is_array($converted['meta']) ? array_merge($converted['meta'], $meta) : $meta;
|
||||
|
||||
$response[] = $converted;
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
@ -209,10 +214,19 @@ class Handler extends ExceptionHandler
|
|||
{
|
||||
$error = [
|
||||
'code' => class_basename($exception),
|
||||
'status' => method_exists($exception, 'getStatusCode') ? strval($exception->getStatusCode()) : '500',
|
||||
'status' => method_exists($exception, 'getStatusCode')
|
||||
? strval($exception->getStatusCode())
|
||||
: ($exception instanceof ValidationException ? '422' : '500'),
|
||||
'detail' => 'An error was encountered while processing this request.',
|
||||
];
|
||||
|
||||
if ($exception instanceof ModelNotFoundException || $exception->getPrevious() instanceof ModelNotFoundException) {
|
||||
// Show a nicer error message compared to the standard "No query results for model"
|
||||
// response that is normally returned. If we are in debug mode this will get overwritten
|
||||
// with a more specific error message to help narrow down things.
|
||||
$error['detail'] = 'The requested resource could not be found on the server.';
|
||||
}
|
||||
|
||||
if (config('app.debug')) {
|
||||
$error = array_merge($error, [
|
||||
'detail' => $exception->getMessage(),
|
||||
|
|
|
@ -9,6 +9,7 @@ use Pterodactyl\Models\Server;
|
|||
use Illuminate\Contracts\View\Factory;
|
||||
use Pterodactyl\Exceptions\DisplayException;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Services\Servers\EnvironmentService;
|
||||
use Pterodactyl\Repositories\Eloquent\NestRepository;
|
||||
use Pterodactyl\Repositories\Eloquent\NodeRepository;
|
||||
use Pterodactyl\Repositories\Eloquent\MountRepository;
|
||||
|
@ -56,6 +57,11 @@ class ServerViewController extends Controller
|
|||
*/
|
||||
private $nodeRepository;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Services\Servers\EnvironmentService
|
||||
*/
|
||||
private $environmentService;
|
||||
|
||||
/**
|
||||
* ServerViewController constructor.
|
||||
*
|
||||
|
@ -66,6 +72,7 @@ class ServerViewController extends Controller
|
|||
* @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository
|
||||
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
|
||||
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
|
||||
* @param \Pterodactyl\Services\Servers\EnvironmentService $environmentService
|
||||
*/
|
||||
public function __construct(
|
||||
Factory $view,
|
||||
|
@ -74,7 +81,8 @@ class ServerViewController extends Controller
|
|||
MountRepository $mountRepository,
|
||||
NestRepository $nestRepository,
|
||||
NodeRepository $nodeRepository,
|
||||
ServerRepository $repository
|
||||
ServerRepository $repository,
|
||||
EnvironmentService $environmentService
|
||||
) {
|
||||
$this->view = $view;
|
||||
$this->databaseHostRepository = $databaseHostRepository;
|
||||
|
@ -83,6 +91,7 @@ class ServerViewController extends Controller
|
|||
$this->nestRepository = $nestRepository;
|
||||
$this->nodeRepository = $nodeRepository;
|
||||
$this->repository = $repository;
|
||||
$this->environmentService = $environmentService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -138,12 +147,12 @@ class ServerViewController extends Controller
|
|||
*/
|
||||
public function startup(Request $request, Server $server)
|
||||
{
|
||||
$parameters = $this->repository->getVariablesWithValues($server->id, true);
|
||||
$nests = $this->nestRepository->getWithEggs();
|
||||
$variables = $this->environmentService->handle($server);
|
||||
|
||||
$this->plainInject([
|
||||
'server' => $server,
|
||||
'server_variables' => $parameters->data,
|
||||
'server_variables' => $variables,
|
||||
'nests' => $nests->map(function (Nest $item) {
|
||||
return array_merge($item->toArray(), [
|
||||
'eggs' => $item->eggs->keyBy('id')->toArray(),
|
||||
|
|
|
@ -82,7 +82,7 @@ class DownloadBackupController extends ClientApiController
|
|||
throw new BadRequestHttpException;
|
||||
}
|
||||
|
||||
return JsonResponse::create([
|
||||
return new JsonResponse([
|
||||
'object' => 'signed_url',
|
||||
'attributes' => [
|
||||
'url' => $url,
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Services\Nodes\NodeJWTService;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
|
||||
|
||||
class FileUploadController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* @var \Pterodactyl\Services\Nodes\NodeJWTService
|
||||
*/
|
||||
private $jwtService;
|
||||
|
||||
/**
|
||||
* FileUploadController constructor.
|
||||
*
|
||||
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
|
||||
*/
|
||||
public function __construct(
|
||||
NodeJWTService $jwtService
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
$this->jwtService = $jwtService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a url where files can be uploaded to.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\UploadFileRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
*
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function __invoke(UploadFileRequest $request, Server $server)
|
||||
{
|
||||
return new JsonResponse([
|
||||
'object' => 'signed_url',
|
||||
'attributes' => [
|
||||
'url' => $this->getUploadUrl($server, $request->user()),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a url where files can be uploaded to.
|
||||
*
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @param \Pterodactyl\Models\User $user
|
||||
* @return string
|
||||
*/
|
||||
protected function getUploadUrl(Server $server, User $user)
|
||||
{
|
||||
$token = $this->jwtService
|
||||
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
|
||||
->setClaims([
|
||||
'server_uuid' => $server->uuid,
|
||||
])
|
||||
->handle($server->node, $user->id . $server->uuid);
|
||||
|
||||
return sprintf(
|
||||
'%s/upload/file?token=%s',
|
||||
$server->node->getConnectionAddress(),
|
||||
$token->__toString()
|
||||
);
|
||||
}
|
||||
}
|
120
app/Http/Controllers/Api/Client/Servers/StartupController.php
Normal file
120
app/Http/Controllers/Api/Client/Servers/StartupController.php
Normal file
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Services\Servers\StartupCommandService;
|
||||
use Pterodactyl\Services\Servers\VariableValidatorService;
|
||||
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
|
||||
use Pterodactyl\Transformers\Api\Client\EggVariableTransformer;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest;
|
||||
|
||||
class StartupController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* @var \Pterodactyl\Services\Servers\VariableValidatorService
|
||||
*/
|
||||
private $service;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Eloquent\ServerVariableRepository
|
||||
*/
|
||||
private $repository;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Services\Servers\StartupCommandService
|
||||
*/
|
||||
private $startupCommandService;
|
||||
|
||||
/**
|
||||
* StartupController constructor.
|
||||
*
|
||||
* @param \Pterodactyl\Services\Servers\VariableValidatorService $service
|
||||
* @param \Pterodactyl\Services\Servers\StartupCommandService $startupCommandService
|
||||
* @param \Pterodactyl\Repositories\Eloquent\ServerVariableRepository $repository
|
||||
*/
|
||||
public function __construct(VariableValidatorService $service, StartupCommandService $startupCommandService, ServerVariableRepository $repository)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->service = $service;
|
||||
$this->repository = $repository;
|
||||
$this->startupCommandService = $startupCommandService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the startup information for the server including all of the variables.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return array
|
||||
*/
|
||||
public function index(GetStartupRequest $request, Server $server)
|
||||
{
|
||||
$startup = $this->startupCommandService->handle($server, false);
|
||||
|
||||
return $this->fractal->collection($server->variables)
|
||||
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
||||
->addMeta([
|
||||
'startup_command' => $startup,
|
||||
'raw_startup_command' => $server->startup,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single variable for a server.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return array
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(UpdateStartupVariableRequest $request, Server $server)
|
||||
{
|
||||
/** @var \Pterodactyl\Models\EggVariable $variable */
|
||||
$variable = $server->variables()->where('env_variable', $request->input('key'))->first();
|
||||
|
||||
if (is_null($variable) || !$variable->user_viewable) {
|
||||
throw new BadRequestHttpException(
|
||||
"The environment variable you are trying to edit does not exist."
|
||||
);
|
||||
} else if (! $variable->user_editable) {
|
||||
throw new BadRequestHttpException(
|
||||
"The environment variable you are trying to edit is read-only."
|
||||
);
|
||||
}
|
||||
|
||||
// Revalidate the variable value using the egg variable specific validation rules for it.
|
||||
$this->validate($request, ['value' => $variable->rules]);
|
||||
|
||||
$this->repository->updateOrCreate([
|
||||
'server_id' => $server->id,
|
||||
'variable_id' => $variable->id,
|
||||
], [
|
||||
'variable_value' => $request->input('value'),
|
||||
]);
|
||||
|
||||
$variable = $variable->refresh();
|
||||
$variable->server_value = $request->input('value');
|
||||
|
||||
$startup = $this->startupCommandService->handle($server, false);
|
||||
|
||||
return $this->fractal->item($variable)
|
||||
->transformWith($this->getTransformer(EggVariableTransformer::class))
|
||||
->addMeta([
|
||||
'startup_command' => $startup,
|
||||
'raw_startup_command' => $server->startup,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
|
@ -3,7 +3,9 @@
|
|||
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Subuser;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
|
||||
|
@ -57,6 +59,21 @@ class SubuserController extends ClientApiController
|
|||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single subuser associated with this server instance.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest $request
|
||||
* @return array
|
||||
*/
|
||||
public function view(GetSubuserRequest $request)
|
||||
{
|
||||
$subuser = $request->attributes->get('subuser');
|
||||
|
||||
return $this->fractal->item($subuser)
|
||||
->transformWith($this->getTransformer(SubuserTransformer::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new subuser for the given server.
|
||||
*
|
||||
|
@ -84,15 +101,16 @@ class SubuserController extends ClientApiController
|
|||
* Update a given subuser in the system for the server.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return array
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(UpdateSubuserRequest $request, Server $server): array
|
||||
public function update(UpdateSubuserRequest $request): array
|
||||
{
|
||||
$subuser = $request->endpointSubuser();
|
||||
/** @var \Pterodactyl\Models\Subuser $subuser */
|
||||
$subuser = $request->attributes->get('subuser');
|
||||
|
||||
$this->repository->update($subuser->id, [
|
||||
'permissions' => $this->getDefaultPermissions($request),
|
||||
]);
|
||||
|
@ -106,14 +124,16 @@ class SubuserController extends ClientApiController
|
|||
* Removes a subusers from a server's assignment.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function delete(DeleteSubuserRequest $request, Server $server)
|
||||
public function delete(DeleteSubuserRequest $request)
|
||||
{
|
||||
$this->repository->delete($request->endpointSubuser()->id);
|
||||
/** @var \Pterodactyl\Models\Subuser $subuser */
|
||||
$subuser = $request->attributes->get('subuser');
|
||||
|
||||
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
|
||||
$this->repository->delete($subuser->id);
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,9 +3,12 @@
|
|||
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
|
||||
|
||||
class BackupStatusController extends Controller
|
||||
|
@ -32,24 +35,27 @@ class BackupStatusController extends Controller
|
|||
* @param string $backup
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function __invoke(ReportBackupCompleteRequest $request, string $backup)
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Backup $backup */
|
||||
$backup = $this->repository->findFirstWhere([['uuid', '=', $backup]]);
|
||||
/** @var \Pterodactyl\Models\Backup $model */
|
||||
$model = $this->repository->findFirstWhere([[ 'uuid', '=', $backup ]]);
|
||||
|
||||
if ($request->input('successful')) {
|
||||
$this->repository->update($backup->id, [
|
||||
'sha256_hash' => $request->input('checksum'),
|
||||
'bytes' => $request->input('size'),
|
||||
'completed_at' => Carbon::now(),
|
||||
], true, true);
|
||||
} else {
|
||||
$this->repository->delete($backup->id);
|
||||
if (!is_null($model->completed_at)) {
|
||||
throw new BadRequestHttpException(
|
||||
'Cannot update the status of a backup that is already marked as completed.'
|
||||
);
|
||||
}
|
||||
|
||||
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
|
||||
$successful = $request->input('successful') ? true : false;
|
||||
$model->forceFill([
|
||||
'is_successful' => $successful,
|
||||
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
|
||||
'bytes' => $successful ? $request->input('size') : 0,
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
])->save();
|
||||
|
||||
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubuserBelongsToServer
|
||||
{
|
||||
/**
|
||||
* Ensure that the user being accessed in the request is a user that is currently assigned
|
||||
* as a subuser for this server instance. We'll let the requests themselves handle wether or
|
||||
* not the user making the request can actually modify or delete the subuser record.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param \Closure $next
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Server $server */
|
||||
$server = $request->route()->parameter('server');
|
||||
/** @var \Pterodactyl\Models\User $user */
|
||||
$user = $request->route()->parameter('user');
|
||||
|
||||
// Don't do anything if there isn't a user present in the request.
|
||||
if (is_null($user)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$request->attributes->set('subuser', $server->subusers()->where('user_id', $user->id)->firstOrFail());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
namespace Pterodactyl\Http\Middleware\Api\Client;
|
||||
|
||||
use Closure;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Models\Database;
|
||||
use Illuminate\Container\Container;
|
||||
|
@ -52,6 +53,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
|
|||
return Backup::query()->where('uuid', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
$this->router->model('user', User::class, function ($value) {
|
||||
return User::query()->where('uuid', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,10 +29,6 @@ class DatabaseHostFormRequest extends AdminFormRequest
|
|||
$this->merge(['node_id' => null]);
|
||||
}
|
||||
|
||||
$this->merge([
|
||||
'host' => gethostbyname($this->input('host')),
|
||||
]);
|
||||
|
||||
return parent::getValidatorInstance();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,14 @@ class StoreApiKeyRequest extends ClientApiRequest
|
|||
'allowed_ips.*' => 'ip',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|string[]
|
||||
*/
|
||||
public function messages()
|
||||
{
|
||||
return [
|
||||
'allowed_ips.*' => 'All of the IP addresses entered must be valid IPv4 addresses.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Files;
|
||||
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class UploadFileRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function permission()
|
||||
{
|
||||
return Permission::ACTION_FILE_CREATE;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
|
||||
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class GetStartupRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function permission()
|
||||
{
|
||||
return Permission::ACTION_STARTUP_READ;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
|
||||
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class UpdateStartupVariableRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function permission()
|
||||
{
|
||||
return Permission::ACTION_STARTUP_UPDATE;
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual validation of the variable's value will happen inside the controller.
|
||||
*
|
||||
* @return array|string[]
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'key' => 'required|string',
|
||||
'value' => 'present|string',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -3,12 +3,10 @@
|
|||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\User;
|
||||
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
|
||||
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Pterodactyl\Services\Servers\GetUserPermissionsService;
|
||||
|
||||
abstract class SubuserRequest extends ClientApiRequest
|
||||
{
|
||||
|
@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest
|
|||
return false;
|
||||
}
|
||||
|
||||
// If there is a subuser present in the URL, validate that it is not the same as the
|
||||
// current request user. You're not allowed to modify yourself.
|
||||
if ($this->route()->hasParameter('subuser')) {
|
||||
if ($this->endpointSubuser()->user_id === $this->user()->id) {
|
||||
$user = $this->route()->parameter('user');
|
||||
// Don't allow a user to edit themselves on the server.
|
||||
if ($user instanceof User) {
|
||||
if ($user->uuid === $this->user()->uuid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest
|
|||
// Otherwise, get the current subuser's permission set, and ensure that the
|
||||
// permissions they are trying to assign are not _more_ than the ones they
|
||||
// already have.
|
||||
if (count(array_diff($permissions, $this->currentUserPermissions())) > 0) {
|
||||
/** @var \Pterodactyl\Models\Subuser|null $subuser */
|
||||
/** @var \Pterodactyl\Services\Servers\GetUserPermissionsService $service */
|
||||
$service = $this->container->make(GetUserPermissionsService::class);
|
||||
|
||||
if (count(array_diff($permissions, $service->handle($server, $user))) > 0) {
|
||||
throw new HttpForbiddenException(
|
||||
'Cannot assign permissions to a subuser that your account does not actively possess.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently authenticated user's permissions.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function currentUserPermissions(): array
|
||||
{
|
||||
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
|
||||
$repository = $this->container->make(SubuserRepository::class);
|
||||
|
||||
/* @var \Pterodactyl\Models\Subuser $model */
|
||||
try {
|
||||
$model = $repository->findFirstWhere([
|
||||
['server_id', $this->route()->parameter('server')->id],
|
||||
['user_id', $this->user()->id],
|
||||
]);
|
||||
} catch (RecordNotFoundException $exception) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $model->permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the subuser model for the given request which can then be validated. If
|
||||
* required request parameters are missing a 404 error will be returned, otherwise
|
||||
* a model exception will be returned if the model is not found.
|
||||
*
|
||||
* This returns the subuser based on the endpoint being hit, not the actual subuser
|
||||
* for the account making the request.
|
||||
*
|
||||
* @return \Pterodactyl\Models\Subuser
|
||||
*
|
||||
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
* @throws \Illuminate\Contracts\Container\BindingResolutionException
|
||||
*/
|
||||
public function endpointSubuser()
|
||||
{
|
||||
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
|
||||
$repository = $this->container->make(SubuserRepository::class);
|
||||
|
||||
$parameters = $this->route()->parameters();
|
||||
if (
|
||||
! isset($parameters['server'], $parameters['server'])
|
||||
|| ! is_string($parameters['subuser'])
|
||||
|| ! $parameters['server'] instanceof Server
|
||||
) {
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
return $this->model ?: $this->model = $repository->getUserForServer(
|
||||
$parameters['server']->id, $parameters['subuser']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,9 @@ class ReportBackupCompleteRequest extends FormRequest
|
|||
public function rules()
|
||||
{
|
||||
return [
|
||||
'successful' => 'boolean',
|
||||
'successful' => 'present|boolean',
|
||||
'checksum' => 'nullable|string|required_if:successful,true',
|
||||
'checksum_type' => 'nullable|string|required_if:successful,true',
|
||||
'size' => 'nullable|numeric|required_if:successful,true',
|
||||
];
|
||||
}
|
||||
|
|
|
@ -8,10 +8,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @property int $id
|
||||
* @property int $server_id
|
||||
* @property int $uuid
|
||||
* @property bool $is_successful
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property string $disk
|
||||
* @property string|null $sha256_hash
|
||||
* @property string|null $checksum
|
||||
* @property int $bytes
|
||||
* @property \Carbon\CarbonImmutable|null $completed_at
|
||||
* @property \Carbon\CarbonImmutable $created_at
|
||||
|
@ -44,6 +45,7 @@ class Backup extends Model
|
|||
*/
|
||||
protected $casts = [
|
||||
'id' => 'int',
|
||||
'is_successful' => 'bool',
|
||||
'bytes' => 'int',
|
||||
'ignored_files' => 'array',
|
||||
];
|
||||
|
@ -59,7 +61,8 @@ class Backup extends Model
|
|||
* @var array
|
||||
*/
|
||||
protected $attributes = [
|
||||
'sha256_hash' => null,
|
||||
'is_successful' => true,
|
||||
'checksum' => null,
|
||||
'bytes' => 0,
|
||||
];
|
||||
|
||||
|
@ -69,10 +72,11 @@ class Backup extends Model
|
|||
public static $validationRules = [
|
||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||
'uuid' => 'required|uuid',
|
||||
'is_successful' => 'boolean',
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'disk' => 'required|string',
|
||||
'sha256_hash' => 'nullable|string',
|
||||
'checksum' => 'nullable|string',
|
||||
'bytes' => 'numeric',
|
||||
];
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
use Pterodactyl\Rules\ResolvesToIPAddress;
|
||||
|
||||
class DatabaseHost extends Model
|
||||
{
|
||||
/**
|
||||
|
@ -51,13 +53,25 @@ class DatabaseHost extends Model
|
|||
*/
|
||||
public static $validationRules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'host' => 'required|unique:database_hosts,host',
|
||||
'host' => 'required|string',
|
||||
'port' => 'required|numeric|between:1,65535',
|
||||
'username' => 'required|string|max:32',
|
||||
'password' => 'nullable|string',
|
||||
'node_id' => 'sometimes|nullable|integer|exists:nodes,id',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public static function getRules()
|
||||
{
|
||||
$rules = parent::getRules();
|
||||
|
||||
$rules['host'] = array_merge($rules['host'], [ new ResolvesToIPAddress() ]);
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the node associated with a database host.
|
||||
*
|
||||
|
|
|
@ -2,6 +2,27 @@
|
|||
|
||||
namespace Pterodactyl\Models;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $egg_id
|
||||
* @property string $name
|
||||
* @property string $description
|
||||
* @property string $env_variable
|
||||
* @property string $default_value
|
||||
* @property bool $user_viewable
|
||||
* @property bool $user_editable
|
||||
* @property string $rules
|
||||
* @property \Carbon\CarbonImmutable $created_at
|
||||
* @property \Carbon\CarbonImmutable $updated_at
|
||||
*
|
||||
* @property bool $required
|
||||
* @property \Pterodactyl\Models\Egg $egg
|
||||
* @property \Pterodactyl\Models\ServerVariable $serverVariable
|
||||
*
|
||||
* The "server_value" variable is only present on the object if you've loaded this model
|
||||
* using the server relationship.
|
||||
* @property string|null $server_value
|
||||
*/
|
||||
class EggVariable extends Model
|
||||
{
|
||||
/**
|
||||
|
@ -17,6 +38,11 @@ class EggVariable extends Model
|
|||
*/
|
||||
const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,SERVER_PORT,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID';
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $immutableDates = true;
|
||||
|
||||
/**
|
||||
* The table associated with the model.
|
||||
*
|
||||
|
@ -38,8 +64,8 @@ class EggVariable extends Model
|
|||
*/
|
||||
protected $casts = [
|
||||
'egg_id' => 'integer',
|
||||
'user_viewable' => 'integer',
|
||||
'user_editable' => 'integer',
|
||||
'user_viewable' => 'bool',
|
||||
'user_editable' => 'bool',
|
||||
];
|
||||
|
||||
/**
|
||||
|
@ -65,12 +91,19 @@ class EggVariable extends Model
|
|||
];
|
||||
|
||||
/**
|
||||
* @param $value
|
||||
* @return bool
|
||||
*/
|
||||
public function getRequiredAttribute($value)
|
||||
public function getRequiredAttribute()
|
||||
{
|
||||
return $this->rules === 'required' || str_contains($this->rules, ['required|', '|required']);
|
||||
return in_array('required', explode('|', $this->rules));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||
*/
|
||||
public function egg()
|
||||
{
|
||||
return $this->hasOne(Egg::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -55,6 +55,9 @@ class Permission extends Model
|
|||
const ACTION_FILE_ARCHIVE = 'file.archive';
|
||||
const ACTION_FILE_SFTP = 'file.sftp';
|
||||
|
||||
const ACTION_STARTUP_READ = 'startup.read';
|
||||
const ACTION_STARTUP_UPDATE = 'startup.update';
|
||||
|
||||
const ACTION_SETTINGS_RENAME = 'settings.rename';
|
||||
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
||||
|
||||
|
@ -169,8 +172,8 @@ class Permission extends Model
|
|||
'startup' => [
|
||||
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
|
||||
'keys' => [
|
||||
'read' => '',
|
||||
'update' => '',
|
||||
'read' => 'Allows a user to view the startup variables for a server.',
|
||||
'update' => 'Allows a user to modify the startup variables for the server.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace Pterodactyl\Models;
|
|||
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Pterodactyl\Models\Traits\Searchable;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Znck\Eloquent\Traits\BelongsToThrough;
|
||||
|
||||
/**
|
||||
|
@ -38,14 +39,14 @@ use Znck\Eloquent\Traits\BelongsToThrough;
|
|||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property \Pterodactyl\Models\User $user
|
||||
* @property \Pterodactyl\Models\User[]|\Illuminate\Database\Eloquent\Collection $subusers
|
||||
* @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subusers
|
||||
* @property \Pterodactyl\Models\Allocation $allocation
|
||||
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
|
||||
* @property \Pterodactyl\Models\Pack|null $pack
|
||||
* @property \Pterodactyl\Models\Node $node
|
||||
* @property \Pterodactyl\Models\Nest $nest
|
||||
* @property \Pterodactyl\Models\Egg $egg
|
||||
* @property \Pterodactyl\Models\ServerVariable[]|\Illuminate\Database\Eloquent\Collection $variables
|
||||
* @property \Pterodactyl\Models\EggVariable[]|\Illuminate\Database\Eloquent\Collection $variables
|
||||
* @property \Pterodactyl\Models\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule
|
||||
* @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases
|
||||
* @property \Pterodactyl\Models\Location $location
|
||||
|
@ -270,7 +271,17 @@ class Server extends Model
|
|||
*/
|
||||
public function variables()
|
||||
{
|
||||
return $this->hasMany(ServerVariable::class);
|
||||
return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id')
|
||||
->select(['egg_variables.*', 'server_variables.variable_value as server_value'])
|
||||
->leftJoin('server_variables', function (JoinClause $join) {
|
||||
// Don't forget to join against the server ID as well since the way we're using this relationship
|
||||
// would actually return all of the variables and their values for _all_ servers using that egg,\
|
||||
// rather than only the server for this model.
|
||||
//
|
||||
// @see https://github.com/pterodactyl/panel/issues/2250
|
||||
$join->on('server_variables.variable_id', 'egg_variables.id')
|
||||
->where('server_variables.server_id', $this->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -27,6 +27,7 @@ class BackupRepository extends EloquentRepository
|
|||
return $this->getBuilder()
|
||||
->withTrashed()
|
||||
->where('server_id', $server)
|
||||
->where('is_successful', true)
|
||||
->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString())
|
||||
->get()
|
||||
->toBase();
|
||||
|
|
|
@ -131,41 +131,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
|
|||
return $server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all of the server variables possible and default to the variable
|
||||
* default if there is no value defined for the specific server requested.
|
||||
*
|
||||
* @param int $id
|
||||
* @param bool $returnAsObject
|
||||
* @return array|object
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function getVariablesWithValues(int $id, bool $returnAsObject = false)
|
||||
{
|
||||
try {
|
||||
$instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns());
|
||||
} catch (ModelNotFoundException $exception) {
|
||||
throw new RecordNotFoundException;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
$instance->getRelation('egg')->getRelation('variables')->each(function ($item) use (&$data, $instance) {
|
||||
$display = $instance->getRelation('variables')->where('variable_id', $item->id)->pluck('variable_value')->first();
|
||||
|
||||
$data[$item->env_variable] = $display ?? $item->default_value;
|
||||
});
|
||||
|
||||
if ($returnAsObject) {
|
||||
return (object) [
|
||||
'data' => $data,
|
||||
'server' => $instance,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return enough data to be used for the creation of a server via the daemon.
|
||||
*
|
||||
|
|
|
@ -18,30 +18,6 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI
|
|||
return Subuser::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a subuser model for the given user and server combination. If no record
|
||||
* exists an exception will be thrown.
|
||||
*
|
||||
* @param int $server
|
||||
* @param string $uuid
|
||||
* @return \Pterodactyl\Models\Subuser
|
||||
*
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public function getUserForServer(int $server, string $uuid): Subuser
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Subuser $model */
|
||||
$model = $this->getBuilder()
|
||||
->with('server', 'user')
|
||||
->select('subusers.*')
|
||||
->join('users', 'users.id', '=', 'subusers.user_id')
|
||||
->where('subusers.server_id', $server)
|
||||
->where('users.uuid', $uuid)
|
||||
->firstOrFail();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a subuser with the associated server relationship.
|
||||
*
|
||||
|
|
49
app/Rules/ResolvesToIPAddress.php
Normal file
49
app/Rules/ResolvesToIPAddress.php
Normal file
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Rules;
|
||||
|
||||
use Illuminate\Contracts\Validation\Rule;
|
||||
|
||||
class ResolvesToIPAddress implements Rule
|
||||
{
|
||||
/**
|
||||
* Validate that a given string can correctly resolve to a valid IPv4 address.
|
||||
*
|
||||
* @param string $attribute
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
public function passes($attribute, $value): bool
|
||||
{
|
||||
// inet_pton returns false if the value passed through is not a valid IP address, so we'll just
|
||||
// use that a nice ugly PHP hack to determine if we should pass this off to the gethostbyname
|
||||
// call below.
|
||||
$isIP = inet_pton($attribute) !== false;
|
||||
|
||||
// If the value received is not an IP address try to look it up using the gethostbyname() call.
|
||||
// If that returns the same value that we passed in then it means it did not resolve to anything
|
||||
// and we should fail this validation call.
|
||||
return $isIP || gethostbyname($value) !== $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a validation message for use when this rule fails.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function message(): string
|
||||
{
|
||||
return 'The :attribute must be a valid IPv4 address or hostname that resolves to a valid IPv4 address.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the rule to a validation string. This is necessary to avoid
|
||||
* issues with Eloquence which tries to use this rule as a string.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return 'p_resolves_to_ip_address';
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Pterodactyl\Services\Backups;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
@ -101,14 +100,14 @@ class InitiateBackupService
|
|||
public function handle(Server $server, string $name = null): Backup
|
||||
{
|
||||
// Do not allow the user to continue if this server is already at its limit.
|
||||
if (! $server->backup_limit || $server->backups()->count() >= $server->backup_limit) {
|
||||
if (! $server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
|
||||
throw new TooManyBackupsException($server->backup_limit);
|
||||
}
|
||||
|
||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10);
|
||||
if ($previous->count() >= 2) {
|
||||
throw new TooManyRequestsHttpException(
|
||||
Carbon::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)),
|
||||
CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)),
|
||||
'Only two backups may be generated within a 10 minute span of time.'
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Pterodactyl\Services\Servers;
|
||||
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\EggVariable;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
|
||||
|
||||
|
@ -63,35 +64,33 @@ class EnvironmentService
|
|||
*
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return array
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function handle(Server $server): array
|
||||
{
|
||||
$variables = $this->repository->getVariablesWithValues($server->id);
|
||||
$variables = $server->variables->toBase()->mapWithKeys(function (EggVariable $variable) {
|
||||
return [$variable->env_variable => $variable->server_value ?? $variable->default_value];
|
||||
});
|
||||
|
||||
// Process environment variables defined in this file. This is done first
|
||||
// in order to allow run-time and config defined variables to take
|
||||
// priority over built-in values.
|
||||
foreach ($this->getEnvironmentMappings() as $key => $object) {
|
||||
$variables[$key] = object_get($server, $object);
|
||||
$variables->put($key, object_get($server, $object));
|
||||
}
|
||||
|
||||
// Process variables set in the configuration file.
|
||||
foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) {
|
||||
if (is_callable($object)) {
|
||||
$variables[$key] = call_user_func($object, $server);
|
||||
} else {
|
||||
$variables[$key] = object_get($server, $object);
|
||||
}
|
||||
$variables->put(
|
||||
$key, is_callable($object) ? call_user_func($object, $server) : object_get($server, $object)
|
||||
);
|
||||
}
|
||||
|
||||
// Process dynamically included environment variables.
|
||||
foreach ($this->additional as $key => $closure) {
|
||||
$variables[$key] = call_user_func($closure, $server);
|
||||
$variables->put($key, call_user_func($closure, $server));
|
||||
}
|
||||
|
||||
return $variables;
|
||||
return $variables->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -30,7 +30,7 @@ class GetUserPermissionsService
|
|||
}
|
||||
|
||||
/** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */
|
||||
$subuserPermissions = $server->subusers->where('user_id', $user->id)->first();
|
||||
$subuserPermissions = $server->subusers()->where('user_id', $user->id)->first();
|
||||
|
||||
return $subuserPermissions ? $subuserPermissions->permissions : [];
|
||||
}
|
||||
|
|
|
@ -242,16 +242,16 @@ class ServerCreationService
|
|||
'io' => Arr::get($data, 'io'),
|
||||
'cpu' => Arr::get($data, 'cpu'),
|
||||
'threads' => Arr::get($data, 'threads'),
|
||||
'oom_disabled' => Arr::get($data, 'oom_disabled', true),
|
||||
'oom_disabled' => Arr::get($data, 'oom_disabled') ?? true,
|
||||
'allocation_id' => Arr::get($data, 'allocation_id'),
|
||||
'nest_id' => Arr::get($data, 'nest_id'),
|
||||
'egg_id' => Arr::get($data, 'egg_id'),
|
||||
'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'],
|
||||
'startup' => Arr::get($data, 'startup'),
|
||||
'image' => Arr::get($data, 'image'),
|
||||
'database_limit' => Arr::get($data, 'database_limit', 0),
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit', 0),
|
||||
'backup_limit' => Arr::get($data, 'backup_limit', 0),
|
||||
'database_limit' => Arr::get($data, 'database_limit') ?? 0,
|
||||
'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0,
|
||||
'backup_limit' => Arr::get($data, 'backup_limit') ?? 0,
|
||||
]);
|
||||
|
||||
return $model;
|
||||
|
|
28
app/Services/Servers/StartupCommandService.php
Normal file
28
app/Services/Servers/StartupCommandService.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Servers;
|
||||
|
||||
use Pterodactyl\Models\Server;
|
||||
|
||||
class StartupCommandService
|
||||
{
|
||||
/**
|
||||
* Generates a startup command for a given server instance.
|
||||
*
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @param bool $hideAllValues
|
||||
* @return string
|
||||
*/
|
||||
public function handle(Server $server, bool $hideAllValues = false): string
|
||||
{
|
||||
$find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}'];
|
||||
$replace = [$server->memory, $server->allocation->ip, $server->allocation->port];
|
||||
|
||||
foreach ($server->variables as $variable) {
|
||||
$find[] = '{{' . $variable->env_variable . '}}';
|
||||
$replace[] = ($variable->user_viewable && !$hideAllValues) ? ($variable->server_value ?? $variable->default_value) : '[hidden]';
|
||||
}
|
||||
|
||||
return str_replace($find, $replace, $server->startup);
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Services\Servers;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
|
||||
|
||||
class StartupCommandViewService
|
||||
{
|
||||
/**
|
||||
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
|
||||
*/
|
||||
private $repository;
|
||||
|
||||
/**
|
||||
* StartupCommandViewService constructor.
|
||||
*
|
||||
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
|
||||
*/
|
||||
public function __construct(ServerRepositoryInterface $repository)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a startup command for a server and return all of the user-viewable variables
|
||||
* as well as their assigned values.
|
||||
*
|
||||
* @param int $server
|
||||
* @return \Illuminate\Support\Collection
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function handle(int $server): Collection
|
||||
{
|
||||
$response = $this->repository->getVariablesWithValues($server, true);
|
||||
$server = $this->repository->getPrimaryAllocation($response->server);
|
||||
|
||||
$find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}'];
|
||||
$replace = [$server->memory, $server->getRelation('allocation')->ip, $server->getRelation('allocation')->port];
|
||||
|
||||
$variables = $server->getRelation('egg')->getRelation('variables')
|
||||
->each(function ($variable) use (&$find, &$replace, $response) {
|
||||
$find[] = '{{' . $variable->env_variable . '}}';
|
||||
$replace[] = $variable->user_viewable ? $response->data[$variable->env_variable] : '[hidden]';
|
||||
})->filter(function ($variable) {
|
||||
return $variable->user_viewable === 1;
|
||||
});
|
||||
|
||||
return collect([
|
||||
'startup' => str_replace($find, $replace, $server->startup),
|
||||
'variables' => $variables,
|
||||
'server_values' => $response->data,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -22,9 +22,10 @@ class BackupTransformer extends BaseClientTransformer
|
|||
{
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'is_successful' => $backup->is_successful,
|
||||
'name' => $backup->name,
|
||||
'ignored_files' => $backup->ignored_files,
|
||||
'sha256_hash' => $backup->sha256_hash,
|
||||
'checksum' => $backup->checksum,
|
||||
'bytes' => $backup->bytes,
|
||||
'created_at' => $backup->created_at->toIso8601String(),
|
||||
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client;
|
|||
|
||||
use Pterodactyl\Models\Database;
|
||||
use League\Fractal\Resource\Item;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Illuminate\Contracts\Encryption\Encrypter;
|
||||
use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||
|
||||
|
@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer
|
|||
/**
|
||||
* Include the database password in the request.
|
||||
*
|
||||
* @param \Pterodactyl\Models\Database $model
|
||||
* @return \League\Fractal\Resource\Item
|
||||
* @param \Pterodactyl\Models\Database $database
|
||||
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
|
||||
*/
|
||||
public function includePassword(Database $model): Item
|
||||
public function includePassword(Database $database): Item
|
||||
{
|
||||
return $this->item($model, function (Database $model) {
|
||||
if (!$this->getUser()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $database->server)) {
|
||||
return $this->null();
|
||||
}
|
||||
|
||||
return $this->item($database, function (Database $model) {
|
||||
return [
|
||||
'password' => $this->encrypter->decrypt($model->password),
|
||||
];
|
||||
|
|
33
app/Transformers/Api/Client/EggVariableTransformer.php
Normal file
33
app/Transformers/Api/Client/EggVariableTransformer.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Transformers\Api\Client;
|
||||
|
||||
use Pterodactyl\Models\EggVariable;
|
||||
|
||||
class EggVariableTransformer extends BaseClientTransformer
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getResourceName(): string
|
||||
{
|
||||
return EggVariable::RESOURCE_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Pterodactyl\Models\EggVariable $variable
|
||||
* @return array
|
||||
*/
|
||||
public function transform(EggVariable $variable)
|
||||
{
|
||||
return [
|
||||
'name' => $variable->name,
|
||||
'description' => $variable->description,
|
||||
'env_variable' => $variable->env_variable,
|
||||
'default_value' => $variable->default_value,
|
||||
'server_value' => $variable->server_value,
|
||||
'is_editable' => $variable->user_editable,
|
||||
'rules' => $variable->rules,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -6,13 +6,17 @@ use Pterodactyl\Models\Egg;
|
|||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Subuser;
|
||||
use Pterodactyl\Models\Allocation;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Illuminate\Container\Container;
|
||||
use Pterodactyl\Models\EggVariable;
|
||||
use Pterodactyl\Services\Servers\StartupCommandService;
|
||||
|
||||
class ServerTransformer extends BaseClientTransformer
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected $defaultIncludes = ['allocations'];
|
||||
protected $defaultIncludes = ['allocations', 'variables'];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
|
@ -36,6 +40,9 @@ class ServerTransformer extends BaseClientTransformer
|
|||
*/
|
||||
public function transform(Server $server): array
|
||||
{
|
||||
/** @var \Pterodactyl\Services\Servers\StartupCommandService $service */
|
||||
$service = Container::getInstance()->make(StartupCommandService::class);
|
||||
|
||||
return [
|
||||
'server_owner' => $this->getKey()->user_id === $server->owner_id,
|
||||
'identifier' => $server->uuidShort,
|
||||
|
@ -54,6 +61,7 @@ class ServerTransformer extends BaseClientTransformer
|
|||
'io' => $server->io,
|
||||
'cpu' => $server->cpu,
|
||||
],
|
||||
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
|
||||
'feature_limits' => [
|
||||
'databases' => $server->database_limit,
|
||||
'allocations' => $server->allocation_limit,
|
||||
|
@ -68,11 +76,16 @@ class ServerTransformer extends BaseClientTransformer
|
|||
* Returns the allocations associated with this server.
|
||||
*
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return \League\Fractal\Resource\Collection
|
||||
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||
*/
|
||||
public function includeAllocations(Server $server)
|
||||
{
|
||||
if (! $this->getUser()->can(Permission::ACTION_ALLOCATION_READ, $server)) {
|
||||
return $this->null();
|
||||
}
|
||||
|
||||
return $this->collection(
|
||||
$server->allocations,
|
||||
$this->makeTransformer(AllocationTransformer::class),
|
||||
|
@ -80,6 +93,25 @@ class ServerTransformer extends BaseClientTransformer
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||
*/
|
||||
public function includeVariables(Server $server)
|
||||
{
|
||||
if (! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)) {
|
||||
return $this->null();
|
||||
}
|
||||
|
||||
return $this->collection(
|
||||
$server->variables->where('user_viewable', true),
|
||||
$this->makeTransformer(EggVariableTransformer::class),
|
||||
EggVariable::RESOURCE_NAME
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the egg associated with this server.
|
||||
*
|
||||
|
@ -96,11 +128,16 @@ class ServerTransformer extends BaseClientTransformer
|
|||
* Returns the subusers associated with this server.
|
||||
*
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @return \League\Fractal\Resource\Collection
|
||||
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||
*/
|
||||
public function includeSubusers(Server $server)
|
||||
{
|
||||
if (! $this->getUser()->can(Permission::ACTION_USER_READ, $server)) {
|
||||
return $this->null();
|
||||
}
|
||||
|
||||
return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -177,7 +177,7 @@ return [
|
|||
| This array includes the MIME filetypes that can be edited via the web.
|
||||
*/
|
||||
'files' => [
|
||||
'max_edit_size' => env('PTERODACTYL_FILES_MAX_EDIT_SIZE', 1024 * 512),
|
||||
'max_edit_size' => env('PTERODACTYL_FILES_MAX_EDIT_SIZE', 1024 * 1024 * 4),
|
||||
'editable' => [
|
||||
'application/json',
|
||||
'application/javascript',
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class AddBackupStateColumnToBackups extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->boolean('is_successful')->after('uuid')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->dropColumn('is_successful');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class UpdateBytesToUnsignedBigint extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->unsignedBigInteger('bytes')->default(0)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->integer('bytes')->default(0)->change();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ModifyChecksumsColumnForBackups extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->renameColumn('sha256_hash', 'checksum');
|
||||
});
|
||||
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
DB::update('UPDATE backups SET checksum = CONCAT(\'sha256:\', checksum)');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->renameColumn('checksum', 'sha256_hash');
|
||||
});
|
||||
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
DB::update('UPDATE backups SET sha256_hash = SUBSTRING(sha256_hash, 8)');
|
||||
});
|
||||
}
|
||||
}
|
|
@ -39,6 +39,8 @@ rules:
|
|||
comma-dangle:
|
||||
- warn
|
||||
- always-multiline
|
||||
spaced-comment:
|
||||
- warn
|
||||
array-bracket-spacing:
|
||||
- warn
|
||||
- always
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Route } from 'react-router';
|
||||
import { SwitchTransition } from 'react-transition-group';
|
||||
import Fade from '@/components/elements/Fade';
|
||||
import styled from 'styled-components/macro';
|
||||
import tw from 'twin.macro';
|
||||
import v4 from 'uuid/v4';
|
||||
|
||||
const StyledSwitchTransition = styled(SwitchTransition)`
|
||||
${tw`relative`};
|
||||
|
@ -13,11 +14,14 @@ const StyledSwitchTransition = styled(SwitchTransition)`
|
|||
}
|
||||
`;
|
||||
|
||||
const TransitionRouter: React.FC = ({ children }) => (
|
||||
const TransitionRouter: React.FC = ({ children }) => {
|
||||
const uuid = useRef(v4()).current;
|
||||
|
||||
return (
|
||||
<Route
|
||||
render={({ location }) => (
|
||||
<StyledSwitchTransition>
|
||||
<Fade timeout={150} key={location.key} in appear unmountOnExit>
|
||||
<Fade timeout={150} key={location.key || uuid} in appear unmountOnExit>
|
||||
<section>
|
||||
{children}
|
||||
</section>
|
||||
|
@ -25,6 +29,7 @@ const TransitionRouter: React.FC = ({ children }) => (
|
|||
</StyledSwitchTransition>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default TransitionRouter;
|
||||
|
|
|
@ -66,6 +66,11 @@ export function httpErrorToHuman (error: any): string {
|
|||
if (data.errors && data.errors[0] && data.errors[0].detail) {
|
||||
return data.errors[0].detail;
|
||||
}
|
||||
|
||||
// Errors from wings directory, mostly just for file uploads.
|
||||
if (data.error && typeof data.error === 'string') {
|
||||
return data.error;
|
||||
}
|
||||
}
|
||||
|
||||
return error.message;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||
import http from '@/api/http';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
import { rawDataToServerBackup } from '@/api/transformers';
|
||||
|
||||
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
|
||||
|
||||
export interface ServerBackup {
|
||||
uuid: string;
|
||||
name: string;
|
||||
ignoredFiles: string;
|
||||
sha256Hash: string;
|
||||
bytes: number;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}
|
||||
|
||||
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
||||
uuid: attributes.uuid,
|
||||
name: attributes.name,
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
sha256Hash: attributes.sha256_hash,
|
||||
bytes: attributes.bytes,
|
||||
createdAt: new Date(attributes.created_at),
|
||||
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
|
||||
});
|
||||
|
||||
export default (uuid: string, page?: number | string): Promise<PaginatedResult<ServerBackup>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
|
||||
.then(({ data }) => resolve({
|
||||
items: (data.data || []).map(rawDataToServerBackup),
|
||||
pagination: getPaginationSet(data.meta.pagination),
|
||||
}))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
9
resources/scripts/api/server/files/getFileUploadUrl.ts
Normal file
9
resources/scripts/api/server/files/getFileUploadUrl.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import http from '@/api/http';
|
||||
|
||||
export default (uuid: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`/api/client/servers/${uuid}/files/upload`)
|
||||
.then(({ data }) => resolve(data.attributes.url))
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import http, { FractalResponseData, FractalResponseList } from '@/api/http';
|
||||
import { rawDataToServerAllocation } from '@/api/transformers';
|
||||
import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers';
|
||||
import { ServerEggVariable } from '@/api/server/types';
|
||||
|
||||
export interface Allocation {
|
||||
id: number;
|
||||
|
@ -19,8 +20,8 @@ export interface Server {
|
|||
ip: string;
|
||||
port: number;
|
||||
};
|
||||
invocation: string;
|
||||
description: string;
|
||||
allocations: Allocation[];
|
||||
limits: {
|
||||
memory: number;
|
||||
swap: number;
|
||||
|
@ -36,6 +37,8 @@ export interface Server {
|
|||
};
|
||||
isSuspended: boolean;
|
||||
isInstalling: boolean;
|
||||
variables: ServerEggVariable[];
|
||||
allocations: Allocation[];
|
||||
}
|
||||
|
||||
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
|
||||
|
@ -43,6 +46,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
|||
uuid: data.uuid,
|
||||
name: data.name,
|
||||
node: data.node,
|
||||
invocation: data.invocation,
|
||||
sftpDetails: {
|
||||
ip: data.sftp_details.ip,
|
||||
port: data.sftp_details.port,
|
||||
|
@ -52,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
|||
featureLimits: { ...data.feature_limits },
|
||||
isSuspended: data.is_suspended,
|
||||
isInstalling: data.is_installing,
|
||||
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
|
||||
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
|
||||
});
|
||||
|
||||
|
|
20
resources/scripts/api/server/types.d.ts
vendored
Normal file
20
resources/scripts/api/server/types.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
export interface ServerBackup {
|
||||
uuid: string;
|
||||
isSuccessful: boolean;
|
||||
name: string;
|
||||
ignoredFiles: string;
|
||||
checksum: string;
|
||||
bytes: number;
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface ServerEggVariable {
|
||||
name: string;
|
||||
description: string;
|
||||
envVariable: string;
|
||||
defaultValue: string;
|
||||
serverValue: string;
|
||||
isEditable: boolean;
|
||||
rules: string[];
|
||||
}
|
9
resources/scripts/api/server/updateStartupVariable.ts
Normal file
9
resources/scripts/api/server/updateStartupVariable.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import http from '@/api/http';
|
||||
import { ServerEggVariable } from '@/api/server/types';
|
||||
import { rawDataToServerEggVariable } from '@/api/transformers';
|
||||
|
||||
export default async (uuid: string, key: string, value: string): Promise<[ ServerEggVariable, string ]> => {
|
||||
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
|
||||
|
||||
return [ rawDataToServerEggVariable(data), data.meta.startup_command ];
|
||||
};
|
18
resources/scripts/api/swr/getServerBackups.ts
Normal file
18
resources/scripts/api/swr/getServerBackups.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import useSWR from 'swr';
|
||||
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
import { rawDataToServerBackup } from '@/api/transformers';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
export default (page?: number | string) => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
|
||||
return useSWR<PaginatedResult<ServerBackup>>([ 'server:backups', uuid, page ], async () => {
|
||||
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
|
||||
|
||||
return ({
|
||||
items: (data.data || []).map(rawDataToServerBackup),
|
||||
pagination: getPaginationSet(data.meta.pagination),
|
||||
});
|
||||
});
|
||||
};
|
18
resources/scripts/api/swr/getServerStartup.ts
Normal file
18
resources/scripts/api/swr/getServerStartup.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import useSWR from 'swr';
|
||||
import http, { FractalResponseList } from '@/api/http';
|
||||
import { rawDataToServerEggVariable } from '@/api/transformers';
|
||||
import { ServerEggVariable } from '@/api/server/types';
|
||||
|
||||
interface Response {
|
||||
invocation: string;
|
||||
variables: ServerEggVariable[];
|
||||
}
|
||||
|
||||
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
|
||||
console.log('firing getServerStartup');
|
||||
const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
|
||||
|
||||
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
|
||||
|
||||
return { invocation: data.meta.startup_command, variables };
|
||||
}, { initialData, errorRetryCount: 3 });
|
|
@ -1,6 +1,7 @@
|
|||
import { Allocation } from '@/api/server/getServer';
|
||||
import { FractalResponseData } from '@/api/http';
|
||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||
import { ServerBackup, ServerEggVariable } from '@/api/server/types';
|
||||
|
||||
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
|
||||
id: data.attributes.id,
|
||||
|
@ -39,3 +40,24 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
|||
].indexOf(this.mimetype) >= 0;
|
||||
},
|
||||
});
|
||||
|
||||
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
||||
uuid: attributes.uuid,
|
||||
isSuccessful: attributes.is_successful,
|
||||
name: attributes.name,
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
checksum: attributes.checksum,
|
||||
bytes: attributes.bytes,
|
||||
createdAt: new Date(attributes.created_at),
|
||||
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
|
||||
});
|
||||
|
||||
export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({
|
||||
name: attributes.name,
|
||||
description: attributes.description,
|
||||
envVariable: attributes.env_variable,
|
||||
defaultValue: attributes.default_value,
|
||||
serverValue: attributes.server_value,
|
||||
isEditable: attributes.is_editable,
|
||||
rules: attributes.rules.split('|'),
|
||||
});
|
||||
|
|
|
@ -43,8 +43,8 @@ const RightNavigation = styled.div`
|
|||
`;
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
||||
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
|
||||
const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin);
|
||||
|
||||
return (
|
||||
<Navigation>
|
||||
|
@ -62,7 +62,7 @@ export default () => {
|
|||
<NavLink to={'/account'}>
|
||||
<FontAwesomeIcon icon={faUserCircle}/>
|
||||
</NavLink>
|
||||
{user.rootAdmin &&
|
||||
{rootAdmin &&
|
||||
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
|
||||
<FontAwesomeIcon icon={faCogs}/>
|
||||
</a>
|
||||
|
|
|
@ -61,21 +61,19 @@ export default () => {
|
|||
</ContentBox>
|
||||
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
|
||||
<SpinnerOverlay visible={loading}/>
|
||||
{deleteIdentifier &&
|
||||
<ConfirmationModal
|
||||
visible
|
||||
visible={!!deleteIdentifier}
|
||||
title={'Confirm key deletion'}
|
||||
buttonText={'Yes, delete key'}
|
||||
onConfirmed={() => {
|
||||
doDeletion(deleteIdentifier);
|
||||
setDeleteIdentifier('');
|
||||
}}
|
||||
onDismissed={() => setDeleteIdentifier('')}
|
||||
onModalDismissed={() => setDeleteIdentifier('')}
|
||||
>
|
||||
Are you sure you wish to delete this API key? All requests using it will immediately be
|
||||
invalidated and will fail.
|
||||
</ConfirmationModal>
|
||||
}
|
||||
{
|
||||
keys.length === 0 ?
|
||||
<p css={tw`text-center text-sm`}>
|
||||
|
|
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal file
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import React, { useContext } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
|
||||
interface Props {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
const ApiKeyModal = ({ apiKey }: Props) => {
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 css={tw`mb-6`}>Your API Key</h3>
|
||||
<p css={tw`text-sm mb-6`}>
|
||||
The API key you have requested is shown below. Please store this in a safe location, it will not be
|
||||
shown again.
|
||||
</p>
|
||||
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>{apiKey}</code>
|
||||
</pre>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button type={'button'} onClick={() => dismiss()}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ApiKeyModal.displayName = 'ApiKeyModal';
|
||||
|
||||
export default asModal<Props>({
|
||||
closeOnEscape: false,
|
||||
closeOnBackground: false,
|
||||
})(ApiKeyModal);
|
|
@ -59,8 +59,18 @@ export default ({ server, className }: { server: Server; className?: string }) =
|
|||
<FontAwesomeIcon icon={faServer}/>
|
||||
</div>
|
||||
<div css={tw`flex-1 ml-4`}>
|
||||
<div css={tw`flex items-center`}>
|
||||
<div
|
||||
css={[
|
||||
tw`w-3 h-3 rounded-full mr-2`,
|
||||
(!stats?.status || stats?.status === 'offline')
|
||||
? tw`bg-red-500`
|
||||
: (stats?.status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`),
|
||||
]}
|
||||
/>
|
||||
<p css={tw`text-lg`}>{server.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div css={tw`w-1/4 overflow-hidden`}>
|
||||
<div css={tw`flex ml-4`}>
|
||||
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
|
||||
|
|
|
@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||
import { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||
import { object, string } from 'yup';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import createApiKey from '@/api/account/createApiKey';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
|
@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys';
|
|||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Input, { Textarea } from '@/components/elements/Input';
|
||||
import styled from 'styled-components/macro';
|
||||
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||
|
||||
interface Values {
|
||||
description: string;
|
||||
allowedIps: string;
|
||||
}
|
||||
|
||||
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
|
||||
|
||||
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||
const [ apiKey, setApiKey ] = useState('');
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
|
@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
<ApiKeyModal
|
||||
visible={apiKey.length > 0}
|
||||
onDismissed={() => setApiKey('')}
|
||||
closeOnEscape={false}
|
||||
closeOnBackground={false}
|
||||
>
|
||||
<h3 css={tw`mb-6`}>Your API Key</h3>
|
||||
<p css={tw`text-sm mb-6`}>
|
||||
The API key you have requested is shown below. Please store this in a safe location, it will not be
|
||||
shown again.
|
||||
</p>
|
||||
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
|
||||
<code css={tw`font-mono`}>{apiKey}</code>
|
||||
</pre>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button
|
||||
type={'button'}
|
||||
onClick={() => setApiKey('')}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
onModalDismissed={() => setApiKey('')}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{
|
||||
description: '',
|
||||
allowedIps: '',
|
||||
}}
|
||||
initialValues={{ description: '', allowedIps: '' }}
|
||||
validationSchema={object().shape({
|
||||
allowedIps: string(),
|
||||
description: string().required().min(4),
|
||||
|
@ -91,7 +73,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
|||
name={'allowedIps'}
|
||||
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
|
||||
>
|
||||
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/>
|
||||
<Field name={'allowedIps'} as={CustomTextarea}/>
|
||||
</FormikFieldWrapper>
|
||||
<div css={tw`flex justify-end mt-6`}>
|
||||
<Button>Create</Button>
|
||||
|
|
|
@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
|
|||
import ace, { Editor } from 'brace';
|
||||
import styled from 'styled-components/macro';
|
||||
import tw from 'twin.macro';
|
||||
import Select from '@/components/elements/Select';
|
||||
// @ts-ignore
|
||||
import modes from '@/modes';
|
||||
|
||||
// @ts-ignore
|
||||
|
@ -21,42 +19,38 @@ const EditorContainer = styled.div`
|
|||
`;
|
||||
|
||||
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
|
||||
const modelist = ace.acequire('ace/ext/modelist');
|
||||
|
||||
export interface Props {
|
||||
style?: React.CSSProperties;
|
||||
initialContent?: string;
|
||||
initialModePath?: string;
|
||||
mode: string;
|
||||
filename?: string;
|
||||
onModeChanged: (mode: string) => void;
|
||||
fetchContent: (callback: () => Promise<string>) => void;
|
||||
onContentSaved: (content: string) => void;
|
||||
onContentSaved: () => void;
|
||||
}
|
||||
|
||||
export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => {
|
||||
const [ mode, setMode ] = useState('ace/mode/plain_text');
|
||||
|
||||
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
|
||||
const [ editor, setEditor ] = useState<Editor>();
|
||||
const ref = useCallback(node => {
|
||||
if (node) {
|
||||
setEditor(ace.edit('editor'));
|
||||
}
|
||||
if (node) setEditor(ace.edit('editor'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
editor && editor.session.setMode(mode);
|
||||
if (modelist && filename) {
|
||||
onModeChanged(modelist.getModeForPath(filename).mode.replace(/^ace\/mode\//, ''));
|
||||
}
|
||||
}, [ filename ]);
|
||||
|
||||
useEffect(() => {
|
||||
editor && editor.session.setMode(`ace/mode/${mode}`);
|
||||
}, [ editor, mode ]);
|
||||
|
||||
useEffect(() => {
|
||||
editor && editor.session.setValue(initialContent || '');
|
||||
}, [ editor, initialContent ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialModePath) {
|
||||
const modelist = ace.acequire('ace/ext/modelist');
|
||||
if (modelist) {
|
||||
setMode(modelist.getModeForPath(initialModePath).mode);
|
||||
}
|
||||
}
|
||||
}, [ initialModePath ]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
fetchContent(() => Promise.reject(new Error('no editor session has been configured')));
|
||||
|
@ -76,7 +70,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
|
|||
editor.commands.addCommand({
|
||||
name: 'Save',
|
||||
bindKey: { win: 'Ctrl-s', mac: 'Command-s' },
|
||||
exec: (editor: Editor) => onContentSaved(editor.session.getValue()),
|
||||
exec: () => onContentSaved(),
|
||||
});
|
||||
|
||||
fetchContent(() => Promise.resolve(editor.session.getValue()));
|
||||
|
@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
|
|||
return (
|
||||
<EditorContainer style={style}>
|
||||
<div id={'editor'} ref={ref}/>
|
||||
<div css={tw`absolute right-0 bottom-0 z-50`}>
|
||||
<div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
|
||||
<Select
|
||||
value={mode.split('/').pop()}
|
||||
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
|
||||
>
|
||||
{
|
||||
Object.keys(modes).map(key => (
|
||||
<option key={key} value={key}>{(modes as { [k: string]: string })[key]}</option>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
interface Props {
|
||||
action: string | string[];
|
||||
|
@ -23,4 +24,4 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Can;
|
||||
export default memo(Can, isEqual);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React from 'react';
|
||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
import React, { useContext } from 'react';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import asModal from '@/hoc/asModal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
|
@ -9,26 +10,29 @@ type Props = {
|
|||
children: string;
|
||||
onConfirmed: () => void;
|
||||
showSpinnerOverlay?: boolean;
|
||||
} & RequiredModalProps;
|
||||
};
|
||||
|
||||
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
|
||||
<Modal
|
||||
appear={appear || true}
|
||||
visible={visible}
|
||||
showSpinnerOverlay={showSpinnerOverlay}
|
||||
onDismissed={() => onDismissed()}
|
||||
>
|
||||
const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => {
|
||||
const { dismiss } = useContext(ModalContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
||||
<p css={tw`text-sm`}>{children}</p>
|
||||
<div css={tw`flex items-center justify-end mt-8`}>
|
||||
<Button isSecondary onClick={() => onDismissed()}>
|
||||
<Button isSecondary onClick={() => dismiss()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
||||
ConfirmationModal.displayName = 'ConfirmationModal';
|
||||
|
||||
export default asModal<Props>(props => ({
|
||||
showSpinnerOverlay: props.showSpinnerOverlay,
|
||||
}))(ConfirmationModal);
|
||||
|
|
|
@ -8,14 +8,14 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
|
|||
}
|
||||
|
||||
const Container = styled.div<{ timeout: number }>`
|
||||
.fade-enter, .fade-exit {
|
||||
.fade-enter, .fade-exit, .fade-appear {
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
.fade-enter, .fade-appear {
|
||||
${tw`opacity-0`};
|
||||
|
||||
&.fade-enter-active {
|
||||
&.fade-enter-active, &.fade-appear-active {
|
||||
${tw`opacity-100 transition-opacity ease-in`};
|
||||
transition-duration: ${props => props.timeout}ms;
|
||||
}
|
||||
|
|
|
@ -13,14 +13,14 @@ export interface RequiredModalProps {
|
|||
top?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends RequiredModalProps {
|
||||
export interface ModalProps extends RequiredModalProps {
|
||||
dismissable?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
closeOnBackground?: boolean;
|
||||
showSpinnerOverlay?: boolean;
|
||||
}
|
||||
|
||||
const ModalMask = styled.div`
|
||||
export const ModalMask = styled.div`
|
||||
${tw`fixed z-50 overflow-auto flex w-full inset-0`};
|
||||
background: rgba(0, 0, 0, 0.70);
|
||||
`;
|
||||
|
@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
|
|||
}
|
||||
`;
|
||||
|
||||
const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
|
||||
const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
|
||||
const [ render, setRender ] = useState(visible);
|
||||
|
||||
const isDismissable = useMemo(() => {
|
||||
|
@ -62,7 +62,13 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
|
|||
}, [ render ]);
|
||||
|
||||
return (
|
||||
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}>
|
||||
<Fade
|
||||
in={render}
|
||||
timeout={150}
|
||||
appear={appear || true}
|
||||
unmountOnExit
|
||||
onExited={() => onDismissed()}
|
||||
>
|
||||
<ModalMask
|
||||
onClick={e => {
|
||||
if (isDismissable && closeOnBackground) {
|
||||
|
@ -80,12 +86,14 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
|
|||
</div>
|
||||
}
|
||||
{showSpinnerOverlay &&
|
||||
<Fade timeout={150} appear in>
|
||||
<div
|
||||
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
||||
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
|
||||
>
|
||||
<Spinner/>
|
||||
</div>
|
||||
</Fade>
|
||||
}
|
||||
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
|
||||
{children}
|
||||
|
|
|
@ -3,10 +3,23 @@ import ContentContainer from '@/components/elements/ContentContainer';
|
|||
import { CSSTransition } from 'react-transition-group';
|
||||
import tw from 'twin.macro';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => (
|
||||
export interface PageContentBlockProps {
|
||||
title?: string;
|
||||
className?: string;
|
||||
showFlashKey?: string;
|
||||
}
|
||||
|
||||
const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey, className, children }) => {
|
||||
return (
|
||||
<CSSTransition timeout={150} classNames={'fade'} appear in>
|
||||
<>
|
||||
{title &&
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
</Helmet>
|
||||
}
|
||||
<ContentContainer css={tw`my-10`} className={className}>
|
||||
{showFlashKey &&
|
||||
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
|
||||
|
@ -28,6 +41,7 @@ const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }>
|
|||
</ContentContainer>
|
||||
</>
|
||||
</CSSTransition>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContentBlock;
|
||||
|
|
19
resources/scripts/components/elements/ServerContentBlock.tsx
Normal file
19
resources/scripts/components/elements/ServerContentBlock.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock';
|
||||
import React from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Props extends PageContentBlockProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const ServerContentBlock: React.FC<Props> = ({ title, children, ...props }) => {
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
|
||||
return (
|
||||
<PageContentBlock title={`${name} | ${title}`} {...props}>
|
||||
{children}
|
||||
</PageContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerContentBlock;
|
|
@ -45,4 +45,10 @@ const Spinner = ({ centered, ...props }: Props) => (
|
|||
);
|
||||
Spinner.DisplayName = 'Spinner';
|
||||
|
||||
Spinner.Size = {
|
||||
SMALL: 'small' as SpinnerSize,
|
||||
BASE: 'base' as SpinnerSize,
|
||||
LARGE: 'large' as SpinnerSize,
|
||||
};
|
||||
|
||||
export default Spinner;
|
||||
|
|
|
@ -52,8 +52,8 @@ export default ({ title, image, message, onBack, onRetry }: Props) => (
|
|||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
<img src={image} css={tw`w-2/3 h-auto select-none`}/>
|
||||
<h2 css={tw`mt-6 text-neutral-900 font-bold`}>{title}</h2>
|
||||
<img src={image} css={tw`w-2/3 h-auto select-none mx-auto`}/>
|
||||
<h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2>
|
||||
<p css={tw`text-sm text-neutral-700 mt-2`}>
|
||||
{message}
|
||||
</p>
|
||||
|
|
|
@ -1,23 +1,22 @@
|
|||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import useServer from '@/plugins/useServer';
|
||||
|
||||
const InstallListener = () => {
|
||||
const server = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
||||
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
|
||||
// Listen for the installation completion event and then fire off a request to fetch the updated
|
||||
// server information. This allows the server to automatically become available to the user if they
|
||||
// just sit on the page.
|
||||
useWebsocketEvent('install completed', () => {
|
||||
getServer(server.uuid).catch(error => console.error(error));
|
||||
getServer(uuid).catch(error => console.error(error));
|
||||
});
|
||||
|
||||
// When we see the install started event immediately update the state to indicate such so that the
|
||||
// screens automatically update.
|
||||
useWebsocketEvent('install started', () => {
|
||||
setServer({ ...server, isInstalling: true });
|
||||
setServerFromState(s => ({ ...s, isInstalling: true }));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { lazy, useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
|
||||
|
@ -7,11 +6,11 @@ import { bytesToHuman, megabytesToHuman } from '@/helpers';
|
|||
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import Can from '@/components/elements/Can';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import ContentContainer from '@/components/elements/ContentContainer';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import StopOrKillButton from '@/components/server/StopOrKillButton';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
|
||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||
|
||||
|
@ -23,10 +22,13 @@ export default () => {
|
|||
const [ cpu, setCpu ] = useState(0);
|
||||
const [ disk, setDisk ] = useState(0);
|
||||
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const name = ServerContext.useStoreState(state => state.server.data!.name);
|
||||
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
|
||||
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
|
||||
const status = ServerContext.useStoreState(state => state.status.value);
|
||||
|
||||
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
|
||||
const connected = ServerContext.useStoreState(state => state.socket.connected);
|
||||
const instance = ServerContext.useStoreState(state => state.socket.instance);
|
||||
|
||||
const statsListener = (data: string) => {
|
||||
let stats: any = {};
|
||||
|
@ -57,16 +59,13 @@ export default () => {
|
|||
};
|
||||
}, [ instance, connected ]);
|
||||
|
||||
const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited';
|
||||
const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : 'Unlimited';
|
||||
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
|
||||
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
|
||||
|
||||
return (
|
||||
<PageContentBlock css={tw`flex`}>
|
||||
<Helmet>
|
||||
<title> {server.name} | Console </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'Console'} css={tw`flex`}>
|
||||
<div css={tw`w-1/4`}>
|
||||
<TitledGreyBox title={server.name} icon={faServer}>
|
||||
<TitledGreyBox title={name} icon={faServer}>
|
||||
<p css={tw`text-xs uppercase`}>
|
||||
<FontAwesomeIcon
|
||||
icon={faCircle}
|
||||
|
@ -90,7 +89,7 @@ export default () => {
|
|||
<span css={tw`text-neutral-500`}> / {disklimit}</span>
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
{!server.isInstalling ?
|
||||
{!isInstalling ?
|
||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
|
||||
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
|
||||
<Can action={'control.start'}>
|
||||
|
@ -143,6 +142,6 @@ export default () => {
|
|||
<ChunkedStatGraphs/>
|
||||
</SuspenseSpinner>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { PowerAction } from '@/components/server/ServerConsole';
|
||||
import Button from '@/components/elements/Button';
|
||||
import isEqual from 'react-fast-compare';
|
||||
|
||||
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
|
||||
const [ clicked, setClicked ] = useState(false);
|
||||
|
@ -27,4 +28,4 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void
|
|||
);
|
||||
};
|
||||
|
||||
export default StopOrKillButton;
|
||||
export default memo(StopOrKillButton, isEqual);
|
||||
|
|
|
@ -1,77 +1,68 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import React, { useEffect } from 'react';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import getServerBackups from '@/api/server/backups/getServerBackups';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import Can from '@/components/elements/Can';
|
||||
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import BackupRow from '@/components/server/backups/BackupRow';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
|
||||
export default () => {
|
||||
const { uuid, featureLimits, name: serverName } = useServer();
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { data: backups, error, isValidating } = getServerBackups();
|
||||
|
||||
const backups = ServerContext.useStoreState(state => state.backups.data);
|
||||
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
|
||||
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
clearFlashes('backups');
|
||||
getServerBackups(uuid)
|
||||
.then(data => setBackups(data.items))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (backups.length === 0 && loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearAndAddHttpError({ error, key: 'backups' });
|
||||
}, [ error ]);
|
||||
|
||||
if (!backups || (error && isValidating)) {
|
||||
return <Spinner size={'large'} centered/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<Helmet>
|
||||
<title> {serverName} | Backups</title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'Backups'}>
|
||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
|
||||
{!backups.length ?
|
||||
{!backups.items.length ?
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
There are no backups stored for this server.
|
||||
</p>
|
||||
:
|
||||
<div>
|
||||
{backups.map((backup, index) => <BackupRow
|
||||
{backups.items.map((backup, index) => <BackupRow
|
||||
key={backup.uuid}
|
||||
backup={backup}
|
||||
css={index > 0 ? tw`mt-2` : undefined}
|
||||
/>)}
|
||||
</div>
|
||||
}
|
||||
{featureLimits.backups === 0 &&
|
||||
{backupLimit === 0 &&
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
Backups cannot be created for this server.
|
||||
</p>
|
||||
}
|
||||
<Can action={'backup.create'}>
|
||||
{(featureLimits.backups > 0 && backups.length > 0) &&
|
||||
{(backupLimit > 0 && backups.items.length > 0) &&
|
||||
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
|
||||
{backups.length} of {featureLimits.backups} backups have been created for this server.
|
||||
{backups.items.length} of {backupLimit} backups have been created for this server.
|
||||
</p>
|
||||
}
|
||||
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
|
||||
{backupLimit > 0 && backupLimit !== backups.items.length &&
|
||||
<div css={tw`mt-6 flex justify-end`}>
|
||||
<CreateBackupButton/>
|
||||
</div>
|
||||
}
|
||||
</Can>
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,31 +1,30 @@
|
|||
import React, { useState } from 'react';
|
||||
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import Can from '@/components/elements/Can';
|
||||
import tw from 'twin.macro';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
}
|
||||
|
||||
export default ({ backup }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ deleteVisible, setDeleteVisible ] = useState(false);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
const doDownload = () => {
|
||||
setLoading(true);
|
||||
|
@ -37,7 +36,7 @@ export default ({ backup }: Props) => {
|
|||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
@ -46,10 +45,15 @@ export default ({ backup }: Props) => {
|
|||
setLoading(true);
|
||||
clearFlashes('backups');
|
||||
deleteBackup(uuid, backup.uuid)
|
||||
.then(() => removeBackup(backup.uuid))
|
||||
.then(() => {
|
||||
mutate(data => ({
|
||||
...data,
|
||||
items: data.items.filter(b => b.uuid !== backup.uuid),
|
||||
}), false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
||||
clearAndAddHttpError({ key: 'backups', error });
|
||||
setLoading(false);
|
||||
setDeleteVisible(false);
|
||||
});
|
||||
|
@ -62,22 +66,21 @@ export default ({ backup }: Props) => {
|
|||
appear
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
checksum={backup.sha256Hash}
|
||||
checksum={backup.checksum}
|
||||
/>
|
||||
}
|
||||
{deleteVisible &&
|
||||
<ConfirmationModal
|
||||
visible={deleteVisible}
|
||||
title={'Delete this backup?'}
|
||||
buttonText={'Yes, delete backup'}
|
||||
onConfirmed={() => doDeletion()}
|
||||
visible={deleteVisible}
|
||||
onDismissed={() => setDeleteVisible(false)}
|
||||
onModalDismissed={() => setDeleteVisible(false)}
|
||||
>
|
||||
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
|
||||
be recovered once deleted.
|
||||
</ConfirmationModal>
|
||||
}
|
||||
<SpinnerOverlay visible={loading} fixed/>
|
||||
{backup.isSuccessful ?
|
||||
<DropdownMenu
|
||||
renderToggle={onClick => (
|
||||
<button
|
||||
|
@ -107,6 +110,14 @@ export default ({ backup }: Props) => {
|
|||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
:
|
||||
<button
|
||||
onClick={() => setDeleteVisible(true)}
|
||||
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt}/>
|
||||
</button>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
||||
import { format, formatDistanceToNow } from 'date-fns';
|
||||
|
@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner';
|
|||
import { bytesToHuman } from '@/helpers';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
||||
import tw from 'twin.macro';
|
||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerBackup } from '@/api/server/types';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
|
@ -18,17 +18,22 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ backup, className }: Props) => {
|
||||
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
appendBackup({
|
||||
...backup,
|
||||
sha256Hash: parsed.sha256_hash || '',
|
||||
|
||||
mutate(data => ({
|
||||
...data,
|
||||
items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
|
||||
...b,
|
||||
isSuccessful: parsed.is_successful || true,
|
||||
checksum: parsed.checksum || '',
|
||||
bytes: parsed.file_size || 0,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
})),
|
||||
}), false);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
|
@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {
|
|||
</div>
|
||||
<div css={tw`flex-1`}>
|
||||
<p css={tw`text-sm mb-1`}>
|
||||
{!backup.isSuccessful &&
|
||||
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
|
||||
Failed
|
||||
</span>
|
||||
}
|
||||
{backup.name}
|
||||
{backup.completedAt &&
|
||||
{(backup.completedAt && backup.isSuccessful) &&
|
||||
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
|
||||
}
|
||||
</p>
|
||||
|
|
|
@ -6,7 +6,7 @@ const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum:
|
|||
<Modal {...props}>
|
||||
<h3 css={tw`mb-6`}>Verify file checksum</h3>
|
||||
<p css={tw`text-sm`}>
|
||||
The SHA256 checksum of this file is:
|
||||
The checksum of this file is:
|
||||
</p>
|
||||
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
||||
<code css={tw`block font-mono`}>{checksum}</code>
|
||||
|
|
|
@ -5,14 +5,13 @@ import { object, string } from 'yup';
|
|||
import Field from '@/components/elements/Field';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import createServerBackup from '@/api/server/backups/createServerBackup';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import Button from '@/components/elements/Button';
|
||||
import tw from 'twin.macro';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
|
@ -59,11 +58,10 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const { uuid } = useServer();
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
|
||||
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
|
||||
const { mutate } = getServerBackups();
|
||||
|
||||
useEffect(() => {
|
||||
clearFlashes('backups:create');
|
||||
|
@ -73,12 +71,11 @@ export default () => {
|
|||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, name, ignored)
|
||||
.then(backup => {
|
||||
appendBackup(backup);
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||
setVisible(false);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
|
||||
clearAndAddHttpError({ key: 'backups:create', error });
|
||||
setSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -8,7 +8,6 @@ import { ServerContext } from '@/state/server';
|
|||
import { httpErrorToHuman } from '@/api/http';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import Button from '@/components/elements/Button';
|
||||
import tw from 'twin.macro';
|
||||
|
||||
|
@ -29,7 +28,7 @@ const schema = object().shape({
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faDatabase, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faDatabase, faEye, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import Modal from '@/components/elements/Modal';
|
||||
import { Form, Formik, FormikHelpers } from 'formik';
|
||||
import Field from '@/components/elements/Field';
|
||||
|
@ -12,7 +12,6 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
@ -26,7 +25,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default ({ database, className }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import getServerDatabases from '@/api/server/getServerDatabases';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
|
@ -9,17 +8,19 @@ import Spinner from '@/components/elements/Spinner';
|
|||
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
import Fade from '@/components/elements/Fade';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||
|
||||
export default () => {
|
||||
const { uuid, featureLimits, name: serverName } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const databaseLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.databases);
|
||||
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
|
||||
const databases = ServerContext.useStoreState(state => state.databases.data);
|
||||
const databases = useDeepMemoize(ServerContext.useStoreState(state => state.databases.data));
|
||||
const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -36,10 +37,7 @@ export default () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<Helmet>
|
||||
<title> {serverName} | Databases </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'Databases'}>
|
||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
|
||||
{(!databases.length && loading) ?
|
||||
<Spinner size={'large'} centered/>
|
||||
|
@ -56,7 +54,7 @@ export default () => {
|
|||
))
|
||||
:
|
||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||
{featureLimits.databases > 0 ?
|
||||
{databaseLimit > 0 ?
|
||||
'It looks like you have no databases.'
|
||||
:
|
||||
'Databases cannot be created for this server.'
|
||||
|
@ -64,13 +62,13 @@ export default () => {
|
|||
</p>
|
||||
}
|
||||
<Can action={'database.create'}>
|
||||
{(featureLimits.databases > 0 && databases.length > 0) &&
|
||||
{(databaseLimit > 0 && databases.length > 0) &&
|
||||
<p css={tw`text-center text-xs text-neutral-400 mt-2`}>
|
||||
{databases.length} of {featureLimits.databases} databases have been allocated to this
|
||||
{databases.length} of {databaseLimit} databases have been allocated to this
|
||||
server.
|
||||
</p>
|
||||
}
|
||||
{featureLimits.databases > 0 && featureLimits.databases !== databases.length &&
|
||||
{databaseLimit > 0 && databaseLimit !== databases.length &&
|
||||
<div css={tw`mt-6 flex justify-end`}>
|
||||
<CreateDatabaseButton/>
|
||||
</div>
|
||||
|
@ -79,6 +77,6 @@ export default () => {
|
|||
</>
|
||||
</Fade>
|
||||
}
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -19,7 +19,6 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
|||
import copyFile from '@/api/server/files/copyFile';
|
||||
import Can from '@/components/elements/Can';
|
||||
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import tw from 'twin.macro';
|
||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||
|
@ -56,7 +55,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
|||
const [ showSpinner, setShowSpinner ] = useState(false);
|
||||
const [ modal, setModal ] = useState<ModalType | null>(null);
|
||||
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearAndAddHttpError, clearFlashes } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
import React, { lazy, useEffect, useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import getFileContents from '@/api/server/files/getFileContents';
|
||||
import { Actions, useStoreActions } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import saveFileContents from '@/api/server/files/saveFileContents';
|
||||
|
@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
|
|||
import ServerError from '@/components/screens/ServerError';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Select from '@/components/elements/Select';
|
||||
import modes from '@/modes';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
|
||||
|
||||
|
@ -24,17 +25,20 @@ export default () => {
|
|||
const [ loading, setLoading ] = useState(action === 'edit');
|
||||
const [ content, setContent ] = useState('');
|
||||
const [ modalVisible, setModalVisible ] = useState(false);
|
||||
const [ mode, setMode ] = useState('plain_text');
|
||||
|
||||
const history = useHistory();
|
||||
const { hash } = useLocation();
|
||||
|
||||
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
|
||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
|
||||
let fetchFileContent: null | (() => Promise<string>) = null;
|
||||
|
||||
if (action !== 'new') {
|
||||
useEffect(() => {
|
||||
if (action === 'new') return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
getFileContents(uuid, hash.replace(/^#/, ''))
|
||||
|
@ -44,8 +48,7 @@ export default () => {
|
|||
setError(httpErrorToHuman(error));
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, [ uuid, hash ]);
|
||||
}
|
||||
}, [ action, uuid, hash ]);
|
||||
|
||||
const save = (name?: string) => {
|
||||
if (!fetchFileContent) {
|
||||
|
@ -75,10 +78,7 @@ export default () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<ServerError
|
||||
message={error}
|
||||
onBack={() => history.goBack()}
|
||||
/>
|
||||
<ServerError message={error} onBack={() => history.goBack()}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -109,15 +109,24 @@ export default () => {
|
|||
<div css={tw`relative`}>
|
||||
<SpinnerOverlay visible={loading}/>
|
||||
<LazyAceEditor
|
||||
initialModePath={hash.replace(/^#/, '') || 'plain_text'}
|
||||
mode={mode}
|
||||
filename={hash.replace(/^#/, '')}
|
||||
onModeChanged={setMode}
|
||||
initialContent={content}
|
||||
fetchContent={value => {
|
||||
fetchFileContent = value;
|
||||
}}
|
||||
onContentSaved={() => save()}
|
||||
onContentSaved={save}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`flex justify-end mt-4`}>
|
||||
<div css={tw`rounded bg-neutral-900 mr-4`}>
|
||||
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}>
|
||||
{Object.keys(modes).map(key => (
|
||||
<option key={key} value={key}>{modes[key]}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{action === 'edit' ?
|
||||
<Can action={'file.update'}>
|
||||
<Button onClick={() => save()}>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useRouteMatch } from 'react-router-dom';
|
||||
import { cleanDirectoryPath } from '@/helpers';
|
||||
import tw from 'twin.macro';
|
||||
import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox';
|
||||
|
@ -13,6 +13,7 @@ interface Props {
|
|||
|
||||
export default ({ withinFileEditor, isNewFile }: Props) => {
|
||||
const [ file, setFile ] = useState<string | null>(null);
|
||||
const { params } = useRouteMatch<Record<string, string>>();
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
|
||||
|
@ -44,7 +45,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
|
|||
|
||||
return (
|
||||
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
|
||||
{!!(files && files.length) &&
|
||||
{(files && files.length > 0 && !params?.action) &&
|
||||
<FileActionCheckbox
|
||||
type={'checkbox'}
|
||||
css={tw`mx-4`}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
|
@ -9,14 +8,14 @@ import { FileObject } from '@/api/server/files/loadDirectory';
|
|||
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import Can from '@/components/elements/Can';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import ServerError from '@/components/screens/ServerError';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||
import MassActionsBar from '@/components/server/files/MassActionsBar';
|
||||
import UploadButton from '@/components/server/files/UploadButton';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
|
||||
const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||
return files.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
@ -24,7 +23,7 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
|
|||
};
|
||||
|
||||
export default () => {
|
||||
const { id, name: serverName } = useServer();
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const { hash } = useLocation();
|
||||
const { data: files, error, mutate } = useFileManagerSwr();
|
||||
|
||||
|
@ -43,10 +42,7 @@ export default () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<PageContentBlock showFlashKey={'files'}>
|
||||
<Helmet>
|
||||
<title> {serverName} | File Manager </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
|
||||
<FileManagerBreadcrumbs/>
|
||||
{
|
||||
!files ?
|
||||
|
@ -80,6 +76,7 @@ export default () => {
|
|||
<Can action={'file.create'}>
|
||||
<div css={tw`flex justify-end mt-8`}>
|
||||
<NewDirectoryButton/>
|
||||
<UploadButton/>
|
||||
<Button
|
||||
// @ts-ignore
|
||||
as={Link}
|
||||
|
@ -91,6 +88,6 @@ export default () => {
|
|||
</Can>
|
||||
</>
|
||||
}
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,14 +8,14 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
|||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import compressFiles from '@/api/server/files/compressFiles';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
import deleteFiles from '@/api/server/files/deleteFiles';
|
||||
import RenameFileModal from '@/components/server/files/RenameFileModal';
|
||||
|
||||
const MassActionsBar = () => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
|
@ -72,7 +72,7 @@ const MassActionsBar = () => {
|
|||
title={'Delete these files?'}
|
||||
buttonText={'Yes, Delete Files'}
|
||||
onConfirmed={onClickConfirmDeletion}
|
||||
onDismissed={() => setShowConfirm(false)}
|
||||
onModalDismissed={() => setShowConfirm(false)}
|
||||
>
|
||||
Deleting files is a permanent operation, you cannot undo this action.
|
||||
</ConfirmationModal>
|
||||
|
|
|
@ -8,7 +8,6 @@ import { object, string } from 'yup';
|
|||
import createDirectory from '@/api/server/files/createDirectory';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||
|
@ -36,7 +35,7 @@ const generateDirectoryData = (name: string): FileObject => ({
|
|||
});
|
||||
|
||||
export default () => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { clearAndAddHttpError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ import renameFiles from '@/api/server/files/renameFiles';
|
|||
import { ServerContext } from '@/state/server';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
|
||||
|
@ -18,7 +17,7 @@ interface FormikValues {
|
|||
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
|
||||
|
||||
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
|
|
103
resources/scripts/components/server/files/UploadButton.tsx
Normal file
103
resources/scripts/components/server/files/UploadButton.tsx
Normal file
|
@ -0,0 +1,103 @@
|
|||
import axios from 'axios';
|
||||
import getFileUploadUrl from '@/api/server/files/getFileUploadUrl';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styled from 'styled-components/macro';
|
||||
import { ModalMask } from '@/components/elements/Modal';
|
||||
import Fade from '@/components/elements/Fade';
|
||||
import useEventListener from '@/plugins/useEventListener';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
const InnerContainer = styled.div`
|
||||
max-width: 600px;
|
||||
${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}
|
||||
`;
|
||||
|
||||
export default () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const { mutate } = useFileManagerSwr();
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||
|
||||
useEventListener('dragenter', e => {
|
||||
e.stopPropagation();
|
||||
setVisible(true);
|
||||
}, true);
|
||||
|
||||
useEventListener('dragexit', e => {
|
||||
e.stopPropagation();
|
||||
setVisible(false);
|
||||
}, true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const hide = () => setVisible(false);
|
||||
|
||||
window.addEventListener('keydown', hide);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', hide);
|
||||
};
|
||||
}, [ visible ]);
|
||||
|
||||
const onFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setVisible(false);
|
||||
if (e.dataTransfer === undefined || e.dataTransfer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
Array.from(e.dataTransfer.files).forEach(file => form.append('files', file));
|
||||
|
||||
setLoading(true);
|
||||
clearFlashes('files');
|
||||
getFileUploadUrl(uuid)
|
||||
.then(url => axios.post(`${url}&directory=${directory}`, form, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}))
|
||||
.then(() => mutate())
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: 'files' });
|
||||
})
|
||||
.then(() => setVisible(false))
|
||||
.then(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fade
|
||||
appear
|
||||
in={visible}
|
||||
timeout={75}
|
||||
key={'upload_modal_mask'}
|
||||
unmountOnExit
|
||||
>
|
||||
<ModalMask onClick={() => setVisible(false)} onDrop={onFileDrop} onDragOver={e => e.preventDefault()}>
|
||||
<div css={tw`w-full flex items-center justify-center`} style={{ pointerEvents: 'none' }}>
|
||||
<InnerContainer>
|
||||
<p css={tw`text-lg text-neutral-200 text-center`}>
|
||||
Drag and drop files to upload.
|
||||
</p>
|
||||
</InnerContainer>
|
||||
</div>
|
||||
</ModalMask>
|
||||
</Fade>
|
||||
<SpinnerOverlay visible={loading} size={'large'}/>
|
||||
<Button css={tw`mr-2`} onClick={() => setVisible(true)}>
|
||||
Upload
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,14 +1,11 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import tw from 'twin.macro';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
||||
import styled from 'styled-components/macro';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
import Button from '@/components/elements/Button';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useSWR from 'swr';
|
||||
import getServerAllocations from '@/api/server/network/getServerAllocations';
|
||||
import { Allocation } from '@/api/server/getServer';
|
||||
|
@ -19,12 +16,17 @@ import { Textarea } from '@/components/elements/Input';
|
|||
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
|
||||
import { debounce } from 'debounce';
|
||||
import InputSpinner from '@/components/elements/InputSpinner';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||
|
||||
const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`;
|
||||
const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
||||
|
||||
const NetworkContainer = () => {
|
||||
const { uuid, allocations, name: serverName } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const allocations = useDeepMemoize(ServerContext.useStoreState(state => state.server.data!.allocations));
|
||||
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const [ loading, setLoading ] = useState<false | number>(false);
|
||||
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
|
||||
|
@ -61,10 +63,7 @@ const NetworkContainer = () => {
|
|||
}, [ error ]);
|
||||
|
||||
return (
|
||||
<PageContentBlock showFlashKey={'server:network'}>
|
||||
<Helmet>
|
||||
<title> {serverName} | Network </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
|
||||
{!data ?
|
||||
<Spinner size={'large'} centered/>
|
||||
:
|
||||
|
@ -112,7 +111,7 @@ const NetworkContainer = () => {
|
|||
</GreyRowBox>
|
||||
))
|
||||
}
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => {
|
|||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
showSpinnerOverlay={isLoading}
|
||||
visible={visible}
|
||||
title={'Delete schedule?'}
|
||||
buttonText={'Yes, delete schedule'}
|
||||
onConfirmed={onDelete}
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
showSpinnerOverlay={isLoading}
|
||||
onModalDismissed={() => setVisible(false)}
|
||||
>
|
||||
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
|
||||
will be terminated.
|
||||
|
|
|
@ -8,7 +8,6 @@ import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedul
|
|||
import { ServerContext } from '@/state/server';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import tw from 'twin.macro';
|
||||
import Button from '@/components/elements/Button';
|
||||
|
@ -75,7 +74,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
|
|||
};
|
||||
|
||||
export default ({ schedule, visible, ...props }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { addError, clearFlashes } = useFlash();
|
||||
const [ modalVisible, setModalVisible ] = useState(visible);
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import getServerSchedules from '@/api/server/schedules/getServerSchedules';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
|
@ -9,15 +8,14 @@ import ScheduleRow from '@/components/server/schedules/ScheduleRow';
|
|||
import { httpErrorToHuman } from '@/api/http';
|
||||
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||
import Button from '@/components/elements/Button';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
|
||||
export default ({ match, history }: RouteComponentProps) => {
|
||||
const { uuid, name: serverName } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const [ loading, setLoading ] = useState(true);
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
|
@ -37,10 +35,7 @@ export default ({ match, history }: RouteComponentProps) => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<Helmet>
|
||||
<title> {serverName} | Schedules </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'Schedules'}>
|
||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||
{(!schedules.length && loading) ?
|
||||
<Spinner size={'large'} centered/>
|
||||
|
@ -77,6 +72,6 @@ export default ({ match, history }: RouteComponentProps) => {
|
|||
</Can>
|
||||
</>
|
||||
}
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -11,7 +11,6 @@ import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
|||
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
|
||||
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
|
@ -28,7 +27,9 @@ interface State {
|
|||
}
|
||||
|
||||
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => {
|
||||
const { id, uuid } = useServer();
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const [ isLoading, setIsLoading ] = useState(true);
|
||||
const [ showEditModal, setShowEditModal ] = useState(false);
|
||||
|
|
|
@ -7,7 +7,6 @@ import { httpErrorToHuman } from '@/api/http';
|
|||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||
import Can from '@/components/elements/Can';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import tw from 'twin.macro';
|
||||
|
@ -32,7 +31,7 @@ const getActionDetails = (action: string): [ string, any ] => {
|
|||
};
|
||||
|
||||
export default ({ schedule, task }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
const [ isLoading, setIsLoading ] = useState(false);
|
||||
|
@ -69,7 +68,7 @@ export default ({ schedule, task }: Props) => {
|
|||
buttonText={'Delete Task'}
|
||||
onConfirmed={onConfirmDeletion}
|
||||
visible={visible}
|
||||
onDismissed={() => setVisible(false)}
|
||||
onModalDismissed={() => setVisible(false)}
|
||||
>
|
||||
Are you sure you want to delete this task? This action cannot be undone.
|
||||
</ConfirmationModal>
|
||||
|
|
|
@ -9,7 +9,6 @@ import Field from '@/components/elements/Field';
|
|||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import { number, object, string } from 'yup';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import useServer from '@/plugins/useServer';
|
||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import tw from 'twin.macro';
|
||||
import Label from '@/components/elements/Label';
|
||||
|
@ -108,7 +107,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
|
|||
};
|
||||
|
||||
export default ({ task, schedule, onDismissed }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const { clearFlashes, addError } = useFlash();
|
||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||
|
||||
|
|
|
@ -46,10 +46,10 @@ export default () => {
|
|||
<ConfirmationModal
|
||||
title={'Confirm server reinstallation'}
|
||||
buttonText={'Yes, reinstall server'}
|
||||
onConfirmed={() => reinstall()}
|
||||
onConfirmed={reinstall}
|
||||
showSpinnerOverlay={isSubmitting}
|
||||
visible={modalVisible}
|
||||
onDismissed={() => setModalVisible(false)}
|
||||
onModalDismissed={() => setModalVisible(false)}
|
||||
>
|
||||
Your server will be stopped and some files may be deleted or modified during this process, are you sure
|
||||
you wish to continue?
|
||||
|
|
|
@ -1,29 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { useStoreState } from 'easy-peasy';
|
||||
import { ApplicationStore } from '@/state';
|
||||
import { UserData } from '@/state/user';
|
||||
import RenameServerBox from '@/components/server/settings/RenameServerBox';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import Can from '@/components/elements/Can';
|
||||
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
|
||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||
import tw from 'twin.macro';
|
||||
import Input from '@/components/elements/Input';
|
||||
import Label from '@/components/elements/Label';
|
||||
import { LinkButton } from '@/components/elements/Button';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
|
||||
export default () => {
|
||||
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
|
||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
||||
const username = useStoreState(state => state.user.data!.username);
|
||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||
const sftpIp = ServerContext.useStoreState(state => state.server.data!.sftpDetails.ip);
|
||||
const sftpPort = ServerContext.useStoreState(state => state.server.data!.sftpDetails.port);
|
||||
|
||||
return (
|
||||
<PageContentBlock>
|
||||
<Helmet>
|
||||
<title> {server.name} | Settings </title>
|
||||
</Helmet>
|
||||
<ServerContentBlock title={'Settings'}>
|
||||
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
|
||||
<div css={tw`md:flex`}>
|
||||
<div css={tw`w-full md:flex-1 md:mr-10`}>
|
||||
|
@ -33,7 +29,7 @@ export default () => {
|
|||
<Label>Server Address</Label>
|
||||
<Input
|
||||
type={'text'}
|
||||
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
value={`sftp://${sftpIp}:${sftpPort}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
@ -41,7 +37,7 @@ export default () => {
|
|||
<Label>Username</Label>
|
||||
<Input
|
||||
type={'text'}
|
||||
value={`${user.username}.${server.id}`}
|
||||
value={`${username}.${id}`}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
@ -56,7 +52,7 @@ export default () => {
|
|||
<div css={tw`ml-4`}>
|
||||
<LinkButton
|
||||
isSecondary
|
||||
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
||||
href={`sftp://${username}.${id}@${sftpIp}:${sftpPort}`}
|
||||
>
|
||||
Launch SFTP
|
||||
</LinkButton>
|
||||
|
@ -76,6 +72,6 @@ export default () => {
|
|||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
</PageContentBlock>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import tw from 'twin.macro';
|
||||
import VariableBox from '@/components/server/startup/VariableBox';
|
||||
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||
import getServerStartup from '@/api/swr/getServerStartup';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import ServerError from '@/components/screens/ServerError';
|
||||
import { httpErrorToHuman } from '@/api/http';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
|
||||
|
||||
const StartupContainer = () => {
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const invocation = ServerContext.useStoreState(state => state.server.data!.invocation);
|
||||
const variables = ServerContext.useStoreState(state => state.server.data!.variables);
|
||||
|
||||
const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables });
|
||||
|
||||
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
|
||||
|
||||
useEffect(() => {
|
||||
// Since we're passing in initial data this will not trigger on mount automatically. We
|
||||
// want to always fetch fresh information from the API however when we're loading the startup
|
||||
// information.
|
||||
mutate();
|
||||
}, []);
|
||||
|
||||
useDeepCompareEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
setServerFromState(s => ({
|
||||
...s,
|
||||
invocation: data.invocation,
|
||||
variables: data.variables,
|
||||
}));
|
||||
}, [ data ]);
|
||||
|
||||
return (
|
||||
!data ?
|
||||
(!error || (error && isValidating)) ?
|
||||
<Spinner centered size={Spinner.Size.LARGE}/>
|
||||
:
|
||||
<ServerError
|
||||
title={'Oops!'}
|
||||
message={httpErrorToHuman(error)}
|
||||
onRetry={() => mutate()}
|
||||
/>
|
||||
:
|
||||
<ServerContentBlock title={'Startup Settings'}>
|
||||
<TitledGreyBox title={'Startup Command'}>
|
||||
<div css={tw`px-1 py-2`}>
|
||||
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
|
||||
{data.invocation}
|
||||
</p>
|
||||
</div>
|
||||
</TitledGreyBox>
|
||||
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
|
||||
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
|
||||
</div>
|
||||
</ServerContentBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default StartupContainer;
|
77
resources/scripts/components/server/startup/VariableBox.tsx
Normal file
77
resources/scripts/components/server/startup/VariableBox.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React, { memo, useState } from 'react';
|
||||
import { ServerEggVariable } from '@/api/server/types';
|
||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||
import { usePermissions } from '@/plugins/usePermissions';
|
||||
import InputSpinner from '@/components/elements/InputSpinner';
|
||||
import Input from '@/components/elements/Input';
|
||||
import tw from 'twin.macro';
|
||||
import { debounce } from 'debounce';
|
||||
import updateStartupVariable from '@/api/server/updateStartupVariable';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||
import getServerStartup from '@/api/swr/getServerStartup';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { ServerContext } from '@/state/server';
|
||||
|
||||
interface Props {
|
||||
variable: ServerEggVariable;
|
||||
}
|
||||
|
||||
const VariableBox = ({ variable }: Props) => {
|
||||
const FLASH_KEY = `server:startup:${variable.envVariable}`;
|
||||
|
||||
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||
const [ loading, setLoading ] = useState(false);
|
||||
const [ canEdit ] = usePermissions([ 'startup.update' ]);
|
||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||
const { mutate } = getServerStartup(uuid);
|
||||
|
||||
const setVariableValue = debounce((value: string) => {
|
||||
setLoading(true);
|
||||
clearFlashes(FLASH_KEY);
|
||||
|
||||
updateStartupVariable(uuid, variable.envVariable, value)
|
||||
.then(([ response, invocation ]) => mutate(data => ({
|
||||
invocation,
|
||||
variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v),
|
||||
}), false))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
clearAndAddHttpError({ error, key: FLASH_KEY });
|
||||
})
|
||||
.then(() => setLoading(false));
|
||||
}, 500);
|
||||
|
||||
return (
|
||||
<TitledGreyBox
|
||||
title={
|
||||
<p css={tw`text-sm uppercase`}>
|
||||
{!variable.isEditable &&
|
||||
<span css={tw`bg-neutral-700 text-xs py-1 px-2 rounded-full mr-2`}>Read Only</span>
|
||||
}
|
||||
{variable.name}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-4`}/>
|
||||
<InputSpinner visible={loading}>
|
||||
<Input
|
||||
onKeyUp={e => {
|
||||
if (canEdit && variable.isEditable) {
|
||||
setVariableValue(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
readOnly={!canEdit || !variable.isEditable}
|
||||
name={variable.envVariable}
|
||||
defaultValue={variable.serverValue}
|
||||
placeholder={variable.defaultValue}
|
||||
/>
|
||||
</InputSpinner>
|
||||
<p css={tw`mt-1 text-xs text-neutral-400`}>
|
||||
{variable.description}
|
||||
</p>
|
||||
</TitledGreyBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(VariableBox, isEqual);
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue