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 \
|
&& cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \
|
||||||
&& rm /usr/local/etc/php-fpm.d/www.conf.default \
|
&& rm /usr/local/etc/php-fpm.d/www.conf.default \
|
||||||
&& cat docker/supervisord.conf > /etc/supervisord.conf \
|
&& 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 \
|
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
|
||||||
&& mkdir -p /var/run/php /var/run/nginx
|
&& 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
|
> 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.
|
> 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 & 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).
|
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)
|
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();
|
$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();
|
$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;
|
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.
|
* 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];
|
return [str_replace('.', '_', $field) => $cleaned];
|
||||||
})->toArray();
|
})->toArray();
|
||||||
|
|
||||||
$errors = collect($exception->errors())->map(function ($errors, $field) use ($codes) {
|
$errors = collect($exception->errors())->map(function ($errors, $field) use ($codes, $exception) {
|
||||||
$response = [];
|
$response = [];
|
||||||
foreach ($errors as $key => $error) {
|
foreach ($errors as $key => $error) {
|
||||||
$response[] = [
|
$meta = [
|
||||||
'code' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get(
|
'source_field' => $field,
|
||||||
|
'rule' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get(
|
||||||
$codes, str_replace('.', '_', $field) . '.' . $key
|
$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;
|
return $response;
|
||||||
|
@ -209,10 +214,19 @@ class Handler extends ExceptionHandler
|
||||||
{
|
{
|
||||||
$error = [
|
$error = [
|
||||||
'code' => class_basename($exception),
|
'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.',
|
'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')) {
|
if (config('app.debug')) {
|
||||||
$error = array_merge($error, [
|
$error = array_merge($error, [
|
||||||
'detail' => $exception->getMessage(),
|
'detail' => $exception->getMessage(),
|
||||||
|
|
|
@ -9,6 +9,7 @@ use Pterodactyl\Models\Server;
|
||||||
use Illuminate\Contracts\View\Factory;
|
use Illuminate\Contracts\View\Factory;
|
||||||
use Pterodactyl\Exceptions\DisplayException;
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
use Pterodactyl\Http\Controllers\Controller;
|
use Pterodactyl\Http\Controllers\Controller;
|
||||||
|
use Pterodactyl\Services\Servers\EnvironmentService;
|
||||||
use Pterodactyl\Repositories\Eloquent\NestRepository;
|
use Pterodactyl\Repositories\Eloquent\NestRepository;
|
||||||
use Pterodactyl\Repositories\Eloquent\NodeRepository;
|
use Pterodactyl\Repositories\Eloquent\NodeRepository;
|
||||||
use Pterodactyl\Repositories\Eloquent\MountRepository;
|
use Pterodactyl\Repositories\Eloquent\MountRepository;
|
||||||
|
@ -56,6 +57,11 @@ class ServerViewController extends Controller
|
||||||
*/
|
*/
|
||||||
private $nodeRepository;
|
private $nodeRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Pterodactyl\Services\Servers\EnvironmentService
|
||||||
|
*/
|
||||||
|
private $environmentService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ServerViewController constructor.
|
* ServerViewController constructor.
|
||||||
*
|
*
|
||||||
|
@ -66,6 +72,7 @@ class ServerViewController extends Controller
|
||||||
* @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository
|
* @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository
|
||||||
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
|
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
|
||||||
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
|
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
|
||||||
|
* @param \Pterodactyl\Services\Servers\EnvironmentService $environmentService
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
Factory $view,
|
Factory $view,
|
||||||
|
@ -74,7 +81,8 @@ class ServerViewController extends Controller
|
||||||
MountRepository $mountRepository,
|
MountRepository $mountRepository,
|
||||||
NestRepository $nestRepository,
|
NestRepository $nestRepository,
|
||||||
NodeRepository $nodeRepository,
|
NodeRepository $nodeRepository,
|
||||||
ServerRepository $repository
|
ServerRepository $repository,
|
||||||
|
EnvironmentService $environmentService
|
||||||
) {
|
) {
|
||||||
$this->view = $view;
|
$this->view = $view;
|
||||||
$this->databaseHostRepository = $databaseHostRepository;
|
$this->databaseHostRepository = $databaseHostRepository;
|
||||||
|
@ -83,6 +91,7 @@ class ServerViewController extends Controller
|
||||||
$this->nestRepository = $nestRepository;
|
$this->nestRepository = $nestRepository;
|
||||||
$this->nodeRepository = $nodeRepository;
|
$this->nodeRepository = $nodeRepository;
|
||||||
$this->repository = $repository;
|
$this->repository = $repository;
|
||||||
|
$this->environmentService = $environmentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -138,12 +147,12 @@ class ServerViewController extends Controller
|
||||||
*/
|
*/
|
||||||
public function startup(Request $request, Server $server)
|
public function startup(Request $request, Server $server)
|
||||||
{
|
{
|
||||||
$parameters = $this->repository->getVariablesWithValues($server->id, true);
|
|
||||||
$nests = $this->nestRepository->getWithEggs();
|
$nests = $this->nestRepository->getWithEggs();
|
||||||
|
$variables = $this->environmentService->handle($server);
|
||||||
|
|
||||||
$this->plainInject([
|
$this->plainInject([
|
||||||
'server' => $server,
|
'server' => $server,
|
||||||
'server_variables' => $parameters->data,
|
'server_variables' => $variables,
|
||||||
'nests' => $nests->map(function (Nest $item) {
|
'nests' => $nests->map(function (Nest $item) {
|
||||||
return array_merge($item->toArray(), [
|
return array_merge($item->toArray(), [
|
||||||
'eggs' => $item->eggs->keyBy('id')->toArray(),
|
'eggs' => $item->eggs->keyBy('id')->toArray(),
|
||||||
|
|
|
@ -82,7 +82,7 @@ class DownloadBackupController extends ClientApiController
|
||||||
throw new BadRequestHttpException;
|
throw new BadRequestHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
return JsonResponse::create([
|
return new JsonResponse([
|
||||||
'object' => 'signed_url',
|
'object' => 'signed_url',
|
||||||
'attributes' => [
|
'attributes' => [
|
||||||
'url' => $url,
|
'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;
|
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
use Pterodactyl\Models\Server;
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Pterodactyl\Models\Permission;
|
use Pterodactyl\Models\Permission;
|
||||||
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
|
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
|
||||||
|
@ -57,6 +59,21 @@ class SubuserController extends ClientApiController
|
||||||
->toArray();
|
->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.
|
* 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.
|
* Update a given subuser in the system for the server.
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
|
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
|
||||||
* @param \Pterodactyl\Models\Server $server
|
|
||||||
* @return array
|
* @return array
|
||||||
*
|
*
|
||||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
* @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, [
|
$this->repository->update($subuser->id, [
|
||||||
'permissions' => $this->getDefaultPermissions($request),
|
'permissions' => $this->getDefaultPermissions($request),
|
||||||
]);
|
]);
|
||||||
|
@ -106,14 +124,16 @@ class SubuserController extends ClientApiController
|
||||||
* Removes a subusers from a server's assignment.
|
* Removes a subusers from a server's assignment.
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
|
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
|
||||||
* @param \Pterodactyl\Models\Server $server
|
|
||||||
* @return \Illuminate\Http\JsonResponse
|
* @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;
|
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Pterodactyl\Http\Controllers\Controller;
|
use Pterodactyl\Http\Controllers\Controller;
|
||||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||||
|
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
|
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
|
||||||
|
|
||||||
class BackupStatusController extends Controller
|
class BackupStatusController extends Controller
|
||||||
|
@ -32,24 +35,27 @@ class BackupStatusController extends Controller
|
||||||
* @param string $backup
|
* @param string $backup
|
||||||
* @return \Illuminate\Http\JsonResponse
|
* @return \Illuminate\Http\JsonResponse
|
||||||
*
|
*
|
||||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
|
||||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||||
*/
|
*/
|
||||||
public function __invoke(ReportBackupCompleteRequest $request, string $backup)
|
public function __invoke(ReportBackupCompleteRequest $request, string $backup)
|
||||||
{
|
{
|
||||||
/** @var \Pterodactyl\Models\Backup $backup */
|
/** @var \Pterodactyl\Models\Backup $model */
|
||||||
$backup = $this->repository->findFirstWhere([['uuid', '=', $backup]]);
|
$model = $this->repository->findFirstWhere([[ 'uuid', '=', $backup ]]);
|
||||||
|
|
||||||
if ($request->input('successful')) {
|
if (!is_null($model->completed_at)) {
|
||||||
$this->repository->update($backup->id, [
|
throw new BadRequestHttpException(
|
||||||
'sha256_hash' => $request->input('checksum'),
|
'Cannot update the status of a backup that is already marked as completed.'
|
||||||
'bytes' => $request->input('size'),
|
);
|
||||||
'completed_at' => Carbon::now(),
|
|
||||||
], true, true);
|
|
||||||
} else {
|
|
||||||
$this->repository->delete($backup->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace Pterodactyl\Http\Middleware\Api\Client;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
use Pterodactyl\Models\Backup;
|
use Pterodactyl\Models\Backup;
|
||||||
use Pterodactyl\Models\Database;
|
use Pterodactyl\Models\Database;
|
||||||
use Illuminate\Container\Container;
|
use Illuminate\Container\Container;
|
||||||
|
@ -52,6 +53,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
|
||||||
return Backup::query()->where('uuid', $value)->firstOrFail();
|
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);
|
return parent::handle($request, $next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,10 +29,6 @@ class DatabaseHostFormRequest extends AdminFormRequest
|
||||||
$this->merge(['node_id' => null]);
|
$this->merge(['node_id' => null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->merge([
|
|
||||||
'host' => gethostbyname($this->input('host')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return parent::getValidatorInstance();
|
return parent::getValidatorInstance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,4 +17,14 @@ class StoreApiKeyRequest extends ClientApiRequest
|
||||||
'allowed_ips.*' => 'ip',
|
'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;
|
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Pterodactyl\Models\Server;
|
use Pterodactyl\Models\User;
|
||||||
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
|
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
|
||||||
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
|
|
||||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
|
use Pterodactyl\Services\Servers\GetUserPermissionsService;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
abstract class SubuserRequest extends ClientApiRequest
|
abstract class SubuserRequest extends ClientApiRequest
|
||||||
{
|
{
|
||||||
|
@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there is a subuser present in the URL, validate that it is not the same as the
|
$user = $this->route()->parameter('user');
|
||||||
// current request user. You're not allowed to modify yourself.
|
// Don't allow a user to edit themselves on the server.
|
||||||
if ($this->route()->hasParameter('subuser')) {
|
if ($user instanceof User) {
|
||||||
if ($this->endpointSubuser()->user_id === $this->user()->id) {
|
if ($user->uuid === $this->user()->uuid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest
|
||||||
// Otherwise, get the current subuser's permission set, and ensure that the
|
// 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
|
// permissions they are trying to assign are not _more_ than the ones they
|
||||||
// already have.
|
// 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(
|
throw new HttpForbiddenException(
|
||||||
'Cannot assign permissions to a subuser that your account does not actively possess.'
|
'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()
|
public function rules()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'successful' => 'boolean',
|
'successful' => 'present|boolean',
|
||||||
'checksum' => 'nullable|string|required_if:successful,true',
|
'checksum' => 'nullable|string|required_if:successful,true',
|
||||||
|
'checksum_type' => 'nullable|string|required_if:successful,true',
|
||||||
'size' => 'nullable|numeric|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 $id
|
||||||
* @property int $server_id
|
* @property int $server_id
|
||||||
* @property int $uuid
|
* @property int $uuid
|
||||||
|
* @property bool $is_successful
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string[] $ignored_files
|
* @property string[] $ignored_files
|
||||||
* @property string $disk
|
* @property string $disk
|
||||||
* @property string|null $sha256_hash
|
* @property string|null $checksum
|
||||||
* @property int $bytes
|
* @property int $bytes
|
||||||
* @property \Carbon\CarbonImmutable|null $completed_at
|
* @property \Carbon\CarbonImmutable|null $completed_at
|
||||||
* @property \Carbon\CarbonImmutable $created_at
|
* @property \Carbon\CarbonImmutable $created_at
|
||||||
|
@ -44,6 +45,7 @@ class Backup extends Model
|
||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'id' => 'int',
|
'id' => 'int',
|
||||||
|
'is_successful' => 'bool',
|
||||||
'bytes' => 'int',
|
'bytes' => 'int',
|
||||||
'ignored_files' => 'array',
|
'ignored_files' => 'array',
|
||||||
];
|
];
|
||||||
|
@ -59,7 +61,8 @@ class Backup extends Model
|
||||||
* @var array
|
* @var array
|
||||||
*/
|
*/
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'sha256_hash' => null,
|
'is_successful' => true,
|
||||||
|
'checksum' => null,
|
||||||
'bytes' => 0,
|
'bytes' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -69,10 +72,11 @@ class Backup extends Model
|
||||||
public static $validationRules = [
|
public static $validationRules = [
|
||||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||||
'uuid' => 'required|uuid',
|
'uuid' => 'required|uuid',
|
||||||
|
'is_successful' => 'boolean',
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'ignored_files' => 'array',
|
'ignored_files' => 'array',
|
||||||
'disk' => 'required|string',
|
'disk' => 'required|string',
|
||||||
'sha256_hash' => 'nullable|string',
|
'checksum' => 'nullable|string',
|
||||||
'bytes' => 'numeric',
|
'bytes' => 'numeric',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
|
use Pterodactyl\Rules\ResolvesToIPAddress;
|
||||||
|
|
||||||
class DatabaseHost extends Model
|
class DatabaseHost extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -51,13 +53,25 @@ class DatabaseHost extends Model
|
||||||
*/
|
*/
|
||||||
public static $validationRules = [
|
public static $validationRules = [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'host' => 'required|unique:database_hosts,host',
|
'host' => 'required|string',
|
||||||
'port' => 'required|numeric|between:1,65535',
|
'port' => 'required|numeric|between:1,65535',
|
||||||
'username' => 'required|string|max:32',
|
'username' => 'required|string|max:32',
|
||||||
'password' => 'nullable|string',
|
'password' => 'nullable|string',
|
||||||
'node_id' => 'sometimes|nullable|integer|exists:nodes,id',
|
'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.
|
* Gets the node associated with a database host.
|
||||||
*
|
*
|
||||||
|
|
|
@ -2,6 +2,27 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
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
|
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';
|
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.
|
* The table associated with the model.
|
||||||
*
|
*
|
||||||
|
@ -38,8 +64,8 @@ class EggVariable extends Model
|
||||||
*/
|
*/
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'egg_id' => 'integer',
|
'egg_id' => 'integer',
|
||||||
'user_viewable' => 'integer',
|
'user_viewable' => 'bool',
|
||||||
'user_editable' => 'integer',
|
'user_editable' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -65,12 +91,19 @@ class EggVariable extends Model
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $value
|
|
||||||
* @return bool
|
* @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_ARCHIVE = 'file.archive';
|
||||||
const ACTION_FILE_SFTP = 'file.sftp';
|
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_RENAME = 'settings.rename';
|
||||||
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
|
||||||
|
|
||||||
|
@ -169,8 +172,8 @@ class Permission extends Model
|
||||||
'startup' => [
|
'startup' => [
|
||||||
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
|
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
|
||||||
'keys' => [
|
'keys' => [
|
||||||
'read' => '',
|
'read' => 'Allows a user to view the startup variables for a server.',
|
||||||
'update' => '',
|
'update' => 'Allows a user to modify the startup variables for the server.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ namespace Pterodactyl\Models;
|
||||||
|
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Pterodactyl\Models\Traits\Searchable;
|
use Pterodactyl\Models\Traits\Searchable;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Znck\Eloquent\Traits\BelongsToThrough;
|
use Znck\Eloquent\Traits\BelongsToThrough;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -38,14 +39,14 @@ use Znck\Eloquent\Traits\BelongsToThrough;
|
||||||
* @property \Carbon\Carbon $updated_at
|
* @property \Carbon\Carbon $updated_at
|
||||||
*
|
*
|
||||||
* @property \Pterodactyl\Models\User $user
|
* @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 $allocation
|
||||||
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
|
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
|
||||||
* @property \Pterodactyl\Models\Pack|null $pack
|
* @property \Pterodactyl\Models\Pack|null $pack
|
||||||
* @property \Pterodactyl\Models\Node $node
|
* @property \Pterodactyl\Models\Node $node
|
||||||
* @property \Pterodactyl\Models\Nest $nest
|
* @property \Pterodactyl\Models\Nest $nest
|
||||||
* @property \Pterodactyl\Models\Egg $egg
|
* @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\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule
|
||||||
* @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases
|
* @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases
|
||||||
* @property \Pterodactyl\Models\Location $location
|
* @property \Pterodactyl\Models\Location $location
|
||||||
|
@ -270,7 +271,17 @@ class Server extends Model
|
||||||
*/
|
*/
|
||||||
public function variables()
|
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()
|
return $this->getBuilder()
|
||||||
->withTrashed()
|
->withTrashed()
|
||||||
->where('server_id', $server)
|
->where('server_id', $server)
|
||||||
|
->where('is_successful', true)
|
||||||
->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString())
|
->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString())
|
||||||
->get()
|
->get()
|
||||||
->toBase();
|
->toBase();
|
||||||
|
|
|
@ -131,41 +131,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
|
||||||
return $server;
|
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.
|
* 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;
|
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.
|
* 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;
|
namespace Pterodactyl\Services\Backups;
|
||||||
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Ramsey\Uuid\Uuid;
|
use Ramsey\Uuid\Uuid;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Webmozart\Assert\Assert;
|
use Webmozart\Assert\Assert;
|
||||||
|
@ -101,14 +100,14 @@ class InitiateBackupService
|
||||||
public function handle(Server $server, string $name = null): Backup
|
public function handle(Server $server, string $name = null): Backup
|
||||||
{
|
{
|
||||||
// Do not allow the user to continue if this server is already at its limit.
|
// 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);
|
throw new TooManyBackupsException($server->backup_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10);
|
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10);
|
||||||
if ($previous->count() >= 2) {
|
if ($previous->count() >= 2) {
|
||||||
throw new TooManyRequestsHttpException(
|
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.'
|
'Only two backups may be generated within a 10 minute span of time.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Pterodactyl\Services\Servers;
|
namespace Pterodactyl\Services\Servers;
|
||||||
|
|
||||||
use Pterodactyl\Models\Server;
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\EggVariable;
|
||||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||||
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
|
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
|
||||||
|
|
||||||
|
@ -63,35 +64,33 @@ class EnvironmentService
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Models\Server $server
|
* @param \Pterodactyl\Models\Server $server
|
||||||
* @return array
|
* @return array
|
||||||
*
|
|
||||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
|
||||||
*/
|
*/
|
||||||
public function handle(Server $server): array
|
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
|
// Process environment variables defined in this file. This is done first
|
||||||
// in order to allow run-time and config defined variables to take
|
// in order to allow run-time and config defined variables to take
|
||||||
// priority over built-in values.
|
// priority over built-in values.
|
||||||
foreach ($this->getEnvironmentMappings() as $key => $object) {
|
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.
|
// Process variables set in the configuration file.
|
||||||
foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) {
|
foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) {
|
||||||
if (is_callable($object)) {
|
$variables->put(
|
||||||
$variables[$key] = call_user_func($object, $server);
|
$key, is_callable($object) ? call_user_func($object, $server) : object_get($server, $object)
|
||||||
} else {
|
);
|
||||||
$variables[$key] = object_get($server, $object);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process dynamically included environment variables.
|
// Process dynamically included environment variables.
|
||||||
foreach ($this->additional as $key => $closure) {
|
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 */
|
/** @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 : [];
|
return $subuserPermissions ? $subuserPermissions->permissions : [];
|
||||||
}
|
}
|
||||||
|
|
|
@ -242,16 +242,16 @@ class ServerCreationService
|
||||||
'io' => Arr::get($data, 'io'),
|
'io' => Arr::get($data, 'io'),
|
||||||
'cpu' => Arr::get($data, 'cpu'),
|
'cpu' => Arr::get($data, 'cpu'),
|
||||||
'threads' => Arr::get($data, 'threads'),
|
'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'),
|
'allocation_id' => Arr::get($data, 'allocation_id'),
|
||||||
'nest_id' => Arr::get($data, 'nest_id'),
|
'nest_id' => Arr::get($data, 'nest_id'),
|
||||||
'egg_id' => Arr::get($data, 'egg_id'),
|
'egg_id' => Arr::get($data, 'egg_id'),
|
||||||
'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'],
|
'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'],
|
||||||
'startup' => Arr::get($data, 'startup'),
|
'startup' => Arr::get($data, 'startup'),
|
||||||
'image' => Arr::get($data, 'image'),
|
'image' => Arr::get($data, 'image'),
|
||||||
'database_limit' => Arr::get($data, 'database_limit', 0),
|
'database_limit' => Arr::get($data, 'database_limit') ?? 0,
|
||||||
'allocation_limit' => Arr::get($data, 'allocation_limit', 0),
|
'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0,
|
||||||
'backup_limit' => Arr::get($data, 'backup_limit', 0),
|
'backup_limit' => Arr::get($data, 'backup_limit') ?? 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $model;
|
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 [
|
return [
|
||||||
'uuid' => $backup->uuid,
|
'uuid' => $backup->uuid,
|
||||||
|
'is_successful' => $backup->is_successful,
|
||||||
'name' => $backup->name,
|
'name' => $backup->name,
|
||||||
'ignored_files' => $backup->ignored_files,
|
'ignored_files' => $backup->ignored_files,
|
||||||
'sha256_hash' => $backup->sha256_hash,
|
'checksum' => $backup->checksum,
|
||||||
'bytes' => $backup->bytes,
|
'bytes' => $backup->bytes,
|
||||||
'created_at' => $backup->created_at->toIso8601String(),
|
'created_at' => $backup->created_at->toIso8601String(),
|
||||||
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,
|
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,
|
||||||
|
|
|
@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client;
|
||||||
|
|
||||||
use Pterodactyl\Models\Database;
|
use Pterodactyl\Models\Database;
|
||||||
use League\Fractal\Resource\Item;
|
use League\Fractal\Resource\Item;
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
use Illuminate\Contracts\Encryption\Encrypter;
|
use Illuminate\Contracts\Encryption\Encrypter;
|
||||||
use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||||
|
|
||||||
|
@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer
|
||||||
/**
|
/**
|
||||||
* Include the database password in the request.
|
* Include the database password in the request.
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Models\Database $model
|
* @param \Pterodactyl\Models\Database $database
|
||||||
* @return \League\Fractal\Resource\Item
|
* @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 [
|
return [
|
||||||
'password' => $this->encrypter->decrypt($model->password),
|
'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\Server;
|
||||||
use Pterodactyl\Models\Subuser;
|
use Pterodactyl\Models\Subuser;
|
||||||
use Pterodactyl\Models\Allocation;
|
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
|
class ServerTransformer extends BaseClientTransformer
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
protected $defaultIncludes = ['allocations'];
|
protected $defaultIncludes = ['allocations', 'variables'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array
|
* @var array
|
||||||
|
@ -36,6 +40,9 @@ class ServerTransformer extends BaseClientTransformer
|
||||||
*/
|
*/
|
||||||
public function transform(Server $server): array
|
public function transform(Server $server): array
|
||||||
{
|
{
|
||||||
|
/** @var \Pterodactyl\Services\Servers\StartupCommandService $service */
|
||||||
|
$service = Container::getInstance()->make(StartupCommandService::class);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'server_owner' => $this->getKey()->user_id === $server->owner_id,
|
'server_owner' => $this->getKey()->user_id === $server->owner_id,
|
||||||
'identifier' => $server->uuidShort,
|
'identifier' => $server->uuidShort,
|
||||||
|
@ -54,6 +61,7 @@ class ServerTransformer extends BaseClientTransformer
|
||||||
'io' => $server->io,
|
'io' => $server->io,
|
||||||
'cpu' => $server->cpu,
|
'cpu' => $server->cpu,
|
||||||
],
|
],
|
||||||
|
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
|
||||||
'feature_limits' => [
|
'feature_limits' => [
|
||||||
'databases' => $server->database_limit,
|
'databases' => $server->database_limit,
|
||||||
'allocations' => $server->allocation_limit,
|
'allocations' => $server->allocation_limit,
|
||||||
|
@ -68,11 +76,16 @@ class ServerTransformer extends BaseClientTransformer
|
||||||
* Returns the allocations associated with this server.
|
* Returns the allocations associated with this server.
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Models\Server $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
|
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||||
*/
|
*/
|
||||||
public function includeAllocations(Server $server)
|
public function includeAllocations(Server $server)
|
||||||
{
|
{
|
||||||
|
if (! $this->getUser()->can(Permission::ACTION_ALLOCATION_READ, $server)) {
|
||||||
|
return $this->null();
|
||||||
|
}
|
||||||
|
|
||||||
return $this->collection(
|
return $this->collection(
|
||||||
$server->allocations,
|
$server->allocations,
|
||||||
$this->makeTransformer(AllocationTransformer::class),
|
$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.
|
* Returns the egg associated with this server.
|
||||||
*
|
*
|
||||||
|
@ -96,11 +128,16 @@ class ServerTransformer extends BaseClientTransformer
|
||||||
* Returns the subusers associated with this server.
|
* Returns the subusers associated with this server.
|
||||||
*
|
*
|
||||||
* @param \Pterodactyl\Models\Server $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
|
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
|
||||||
*/
|
*/
|
||||||
public function includeSubusers(Server $server)
|
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);
|
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.
|
| This array includes the MIME filetypes that can be edited via the web.
|
||||||
*/
|
*/
|
||||||
'files' => [
|
'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' => [
|
'editable' => [
|
||||||
'application/json',
|
'application/json',
|
||||||
'application/javascript',
|
'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:
|
comma-dangle:
|
||||||
- warn
|
- warn
|
||||||
- always-multiline
|
- always-multiline
|
||||||
|
spaced-comment:
|
||||||
|
- warn
|
||||||
array-bracket-spacing:
|
array-bracket-spacing:
|
||||||
- warn
|
- warn
|
||||||
- always
|
- always
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Route } from 'react-router';
|
import { Route } from 'react-router';
|
||||||
import { SwitchTransition } from 'react-transition-group';
|
import { SwitchTransition } from 'react-transition-group';
|
||||||
import Fade from '@/components/elements/Fade';
|
import Fade from '@/components/elements/Fade';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components/macro';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
import v4 from 'uuid/v4';
|
||||||
|
|
||||||
const StyledSwitchTransition = styled(SwitchTransition)`
|
const StyledSwitchTransition = styled(SwitchTransition)`
|
||||||
${tw`relative`};
|
${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
|
<Route
|
||||||
render={({ location }) => (
|
render={({ location }) => (
|
||||||
<StyledSwitchTransition>
|
<StyledSwitchTransition>
|
||||||
<Fade timeout={150} key={location.key} in appear unmountOnExit>
|
<Fade timeout={150} key={location.key || uuid} in appear unmountOnExit>
|
||||||
<section>
|
<section>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
|
@ -26,5 +30,6 @@ const TransitionRouter: React.FC = ({ children }) => (
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default TransitionRouter;
|
export default TransitionRouter;
|
||||||
|
|
|
@ -66,6 +66,11 @@ export function httpErrorToHuman (error: any): string {
|
||||||
if (data.errors && data.errors[0] && data.errors[0].detail) {
|
if (data.errors && data.errors[0] && data.errors[0].detail) {
|
||||||
return 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;
|
return error.message;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
|
|
||||||
import http from '@/api/http';
|
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> => {
|
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
|
||||||
return new Promise((resolve, reject) => {
|
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 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 {
|
export interface Allocation {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -19,8 +20,8 @@ export interface Server {
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
invocation: string;
|
||||||
description: string;
|
description: string;
|
||||||
allocations: Allocation[];
|
|
||||||
limits: {
|
limits: {
|
||||||
memory: number;
|
memory: number;
|
||||||
swap: number;
|
swap: number;
|
||||||
|
@ -36,6 +37,8 @@ export interface Server {
|
||||||
};
|
};
|
||||||
isSuspended: boolean;
|
isSuspended: boolean;
|
||||||
isInstalling: boolean;
|
isInstalling: boolean;
|
||||||
|
variables: ServerEggVariable[];
|
||||||
|
allocations: Allocation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
|
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
|
||||||
|
@ -43,6 +46,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
node: data.node,
|
node: data.node,
|
||||||
|
invocation: data.invocation,
|
||||||
sftpDetails: {
|
sftpDetails: {
|
||||||
ip: data.sftp_details.ip,
|
ip: data.sftp_details.ip,
|
||||||
port: data.sftp_details.port,
|
port: data.sftp_details.port,
|
||||||
|
@ -52,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
|
||||||
featureLimits: { ...data.feature_limits },
|
featureLimits: { ...data.feature_limits },
|
||||||
isSuspended: data.is_suspended,
|
isSuspended: data.is_suspended,
|
||||||
isInstalling: data.is_installing,
|
isInstalling: data.is_installing,
|
||||||
|
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
|
||||||
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
|
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 { Allocation } from '@/api/server/getServer';
|
||||||
import { FractalResponseData } from '@/api/http';
|
import { FractalResponseData } from '@/api/http';
|
||||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||||
|
import { ServerBackup, ServerEggVariable } from '@/api/server/types';
|
||||||
|
|
||||||
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
|
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
|
||||||
id: data.attributes.id,
|
id: data.attributes.id,
|
||||||
|
@ -39,3 +40,24 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
||||||
].indexOf(this.mimetype) >= 0;
|
].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 () => {
|
export default () => {
|
||||||
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
|
||||||
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
|
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
|
||||||
|
const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navigation>
|
<Navigation>
|
||||||
|
@ -62,7 +62,7 @@ export default () => {
|
||||||
<NavLink to={'/account'}>
|
<NavLink to={'/account'}>
|
||||||
<FontAwesomeIcon icon={faUserCircle}/>
|
<FontAwesomeIcon icon={faUserCircle}/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{user.rootAdmin &&
|
{rootAdmin &&
|
||||||
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
|
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
|
||||||
<FontAwesomeIcon icon={faCogs}/>
|
<FontAwesomeIcon icon={faCogs}/>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -61,21 +61,19 @@ export default () => {
|
||||||
</ContentBox>
|
</ContentBox>
|
||||||
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
|
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
|
||||||
<SpinnerOverlay visible={loading}/>
|
<SpinnerOverlay visible={loading}/>
|
||||||
{deleteIdentifier &&
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
visible
|
visible={!!deleteIdentifier}
|
||||||
title={'Confirm key deletion'}
|
title={'Confirm key deletion'}
|
||||||
buttonText={'Yes, delete key'}
|
buttonText={'Yes, delete key'}
|
||||||
onConfirmed={() => {
|
onConfirmed={() => {
|
||||||
doDeletion(deleteIdentifier);
|
doDeletion(deleteIdentifier);
|
||||||
setDeleteIdentifier('');
|
setDeleteIdentifier('');
|
||||||
}}
|
}}
|
||||||
onDismissed={() => setDeleteIdentifier('')}
|
onModalDismissed={() => setDeleteIdentifier('')}
|
||||||
>
|
>
|
||||||
Are you sure you wish to delete this API key? All requests using it will immediately be
|
Are you sure you wish to delete this API key? All requests using it will immediately be
|
||||||
invalidated and will fail.
|
invalidated and will fail.
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
}
|
|
||||||
{
|
{
|
||||||
keys.length === 0 ?
|
keys.length === 0 ?
|
||||||
<p css={tw`text-center text-sm`}>
|
<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}/>
|
<FontAwesomeIcon icon={faServer}/>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`flex-1 ml-4`}>
|
<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>
|
<p css={tw`text-lg`}>{server.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div css={tw`w-1/4 overflow-hidden`}>
|
<div css={tw`w-1/4 overflow-hidden`}>
|
||||||
<div css={tw`flex ml-4`}>
|
<div css={tw`flex ml-4`}>
|
||||||
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
|
<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 { Field, Form, Formik, FormikHelpers } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { object, string } from 'yup';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
import Modal from '@/components/elements/Modal';
|
|
||||||
import createApiKey from '@/api/account/createApiKey';
|
import createApiKey from '@/api/account/createApiKey';
|
||||||
import { Actions, useStoreActions } from 'easy-peasy';
|
import { Actions, useStoreActions } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
|
@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Input, { Textarea } from '@/components/elements/Input';
|
import Input, { Textarea } from '@/components/elements/Input';
|
||||||
|
import styled from 'styled-components/macro';
|
||||||
|
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
description: string;
|
description: string;
|
||||||
allowedIps: string;
|
allowedIps: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
|
||||||
|
|
||||||
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||||
const [ apiKey, setApiKey ] = useState('');
|
const [ apiKey, setApiKey ] = useState('');
|
||||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||||
|
@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<ApiKeyModal
|
||||||
visible={apiKey.length > 0}
|
visible={apiKey.length > 0}
|
||||||
onDismissed={() => setApiKey('')}
|
onModalDismissed={() => setApiKey('')}
|
||||||
closeOnEscape={false}
|
apiKey={apiKey}
|
||||||
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>
|
|
||||||
<Formik
|
<Formik
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
initialValues={{
|
initialValues={{ description: '', allowedIps: '' }}
|
||||||
description: '',
|
|
||||||
allowedIps: '',
|
|
||||||
}}
|
|
||||||
validationSchema={object().shape({
|
validationSchema={object().shape({
|
||||||
allowedIps: string(),
|
allowedIps: string(),
|
||||||
description: string().required().min(4),
|
description: string().required().min(4),
|
||||||
|
@ -91,7 +73,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
|
||||||
name={'allowedIps'}
|
name={'allowedIps'}
|
||||||
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
|
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>
|
</FormikFieldWrapper>
|
||||||
<div css={tw`flex justify-end mt-6`}>
|
<div css={tw`flex justify-end mt-6`}>
|
||||||
<Button>Create</Button>
|
<Button>Create</Button>
|
||||||
|
|
|
@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import ace, { Editor } from 'brace';
|
import ace, { Editor } from 'brace';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components/macro';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Select from '@/components/elements/Select';
|
|
||||||
// @ts-ignore
|
|
||||||
import modes from '@/modes';
|
import modes from '@/modes';
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -21,42 +19,38 @@ const EditorContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
|
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
|
||||||
|
const modelist = ace.acequire('ace/ext/modelist');
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
initialModePath?: string;
|
mode: string;
|
||||||
|
filename?: string;
|
||||||
|
onModeChanged: (mode: string) => void;
|
||||||
fetchContent: (callback: () => Promise<string>) => void;
|
fetchContent: (callback: () => Promise<string>) => void;
|
||||||
onContentSaved: (content: string) => void;
|
onContentSaved: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => {
|
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
|
||||||
const [ mode, setMode ] = useState('ace/mode/plain_text');
|
|
||||||
|
|
||||||
const [ editor, setEditor ] = useState<Editor>();
|
const [ editor, setEditor ] = useState<Editor>();
|
||||||
const ref = useCallback(node => {
|
const ref = useCallback(node => {
|
||||||
if (node) {
|
if (node) setEditor(ace.edit('editor'));
|
||||||
setEditor(ace.edit('editor'));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
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 ]);
|
}, [ editor, mode ]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
editor && editor.session.setValue(initialContent || '');
|
editor && editor.session.setValue(initialContent || '');
|
||||||
}, [ editor, initialContent ]);
|
}, [ editor, initialContent ]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialModePath) {
|
|
||||||
const modelist = ace.acequire('ace/ext/modelist');
|
|
||||||
if (modelist) {
|
|
||||||
setMode(modelist.getModeForPath(initialModePath).mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ initialModePath ]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
fetchContent(() => Promise.reject(new Error('no editor session has been configured')));
|
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({
|
editor.commands.addCommand({
|
||||||
name: 'Save',
|
name: 'Save',
|
||||||
bindKey: { win: 'Ctrl-s', mac: 'Command-s' },
|
bindKey: { win: 'Ctrl-s', mac: 'Command-s' },
|
||||||
exec: (editor: Editor) => onContentSaved(editor.session.getValue()),
|
exec: () => onContentSaved(),
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchContent(() => Promise.resolve(editor.session.getValue()));
|
fetchContent(() => Promise.resolve(editor.session.getValue()));
|
||||||
|
@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
|
||||||
return (
|
return (
|
||||||
<EditorContainer style={style}>
|
<EditorContainer style={style}>
|
||||||
<div id={'editor'} ref={ref}/>
|
<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>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React, { memo } from 'react';
|
||||||
import { usePermissions } from '@/plugins/usePermissions';
|
import { usePermissions } from '@/plugins/usePermissions';
|
||||||
|
import isEqual from 'react-fast-compare';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string | string[];
|
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 React, { useContext } from 'react';
|
||||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
import asModal from '@/hoc/asModal';
|
||||||
|
import ModalContext from '@/context/ModalContext';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -9,26 +10,29 @@ type Props = {
|
||||||
children: string;
|
children: string;
|
||||||
onConfirmed: () => void;
|
onConfirmed: () => void;
|
||||||
showSpinnerOverlay?: boolean;
|
showSpinnerOverlay?: boolean;
|
||||||
} & RequiredModalProps;
|
};
|
||||||
|
|
||||||
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
|
const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => {
|
||||||
<Modal
|
const { dismiss } = useContext(ModalContext);
|
||||||
appear={appear || true}
|
|
||||||
visible={visible}
|
return (
|
||||||
showSpinnerOverlay={showSpinnerOverlay}
|
<>
|
||||||
onDismissed={() => onDismissed()}
|
|
||||||
>
|
|
||||||
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
|
||||||
<p css={tw`text-sm`}>{children}</p>
|
<p css={tw`text-sm`}>{children}</p>
|
||||||
<div css={tw`flex items-center justify-end mt-8`}>
|
<div css={tw`flex items-center justify-end mt-8`}>
|
||||||
<Button isSecondary onClick={() => onDismissed()}>
|
<Button isSecondary onClick={() => dismiss()}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
|
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 }>`
|
const Container = styled.div<{ timeout: number }>`
|
||||||
.fade-enter, .fade-exit {
|
.fade-enter, .fade-exit, .fade-appear {
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter {
|
.fade-enter, .fade-appear {
|
||||||
${tw`opacity-0`};
|
${tw`opacity-0`};
|
||||||
|
|
||||||
&.fade-enter-active {
|
&.fade-enter-active, &.fade-appear-active {
|
||||||
${tw`opacity-100 transition-opacity ease-in`};
|
${tw`opacity-100 transition-opacity ease-in`};
|
||||||
transition-duration: ${props => props.timeout}ms;
|
transition-duration: ${props => props.timeout}ms;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,14 +13,14 @@ export interface RequiredModalProps {
|
||||||
top?: boolean;
|
top?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends RequiredModalProps {
|
export interface ModalProps extends RequiredModalProps {
|
||||||
dismissable?: boolean;
|
dismissable?: boolean;
|
||||||
closeOnEscape?: boolean;
|
closeOnEscape?: boolean;
|
||||||
closeOnBackground?: boolean;
|
closeOnBackground?: boolean;
|
||||||
showSpinnerOverlay?: boolean;
|
showSpinnerOverlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalMask = styled.div`
|
export const ModalMask = styled.div`
|
||||||
${tw`fixed z-50 overflow-auto flex w-full inset-0`};
|
${tw`fixed z-50 overflow-auto flex w-full inset-0`};
|
||||||
background: rgba(0, 0, 0, 0.70);
|
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 [ render, setRender ] = useState(visible);
|
||||||
|
|
||||||
const isDismissable = useMemo(() => {
|
const isDismissable = useMemo(() => {
|
||||||
|
@ -62,7 +62,13 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
|
||||||
}, [ render ]);
|
}, [ render ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}>
|
<Fade
|
||||||
|
in={render}
|
||||||
|
timeout={150}
|
||||||
|
appear={appear || true}
|
||||||
|
unmountOnExit
|
||||||
|
onExited={() => onDismissed()}
|
||||||
|
>
|
||||||
<ModalMask
|
<ModalMask
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
if (isDismissable && closeOnBackground) {
|
if (isDismissable && closeOnBackground) {
|
||||||
|
@ -80,12 +86,14 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{showSpinnerOverlay &&
|
{showSpinnerOverlay &&
|
||||||
|
<Fade timeout={150} appear in>
|
||||||
<div
|
<div
|
||||||
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
css={tw`absolute w-full h-full rounded flex items-center justify-center`}
|
||||||
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
|
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
|
||||||
>
|
>
|
||||||
<Spinner/>
|
<Spinner/>
|
||||||
</div>
|
</div>
|
||||||
|
</Fade>
|
||||||
}
|
}
|
||||||
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
|
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
@ -3,10 +3,23 @@ import ContentContainer from '@/components/elements/ContentContainer';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
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>
|
<CSSTransition timeout={150} classNames={'fade'} appear in>
|
||||||
<>
|
<>
|
||||||
|
{title &&
|
||||||
|
<Helmet>
|
||||||
|
<title>{title}</title>
|
||||||
|
</Helmet>
|
||||||
|
}
|
||||||
<ContentContainer css={tw`my-10`} className={className}>
|
<ContentContainer css={tw`my-10`} className={className}>
|
||||||
{showFlashKey &&
|
{showFlashKey &&
|
||||||
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
|
||||||
|
@ -29,5 +42,6 @@ const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }>
|
||||||
</>
|
</>
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default PageContentBlock;
|
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.DisplayName = 'Spinner';
|
||||||
|
|
||||||
|
Spinner.Size = {
|
||||||
|
SMALL: 'small' as SpinnerSize,
|
||||||
|
BASE: 'base' as SpinnerSize,
|
||||||
|
LARGE: 'large' as SpinnerSize,
|
||||||
|
};
|
||||||
|
|
||||||
export default Spinner;
|
export default Spinner;
|
||||||
|
|
|
@ -52,8 +52,8 @@ export default ({ title, image, message, onBack, onRetry }: Props) => (
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<img src={image} css={tw`w-2/3 h-auto select-none`}/>
|
<img src={image} css={tw`w-2/3 h-auto select-none mx-auto`}/>
|
||||||
<h2 css={tw`mt-6 text-neutral-900 font-bold`}>{title}</h2>
|
<h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2>
|
||||||
<p css={tw`text-sm text-neutral-700 mt-2`}>
|
<p css={tw`text-sm text-neutral-700 mt-2`}>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -1,23 +1,22 @@
|
||||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
|
|
||||||
const InstallListener = () => {
|
const InstallListener = () => {
|
||||||
const server = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
|
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
|
// 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
|
// server information. This allows the server to automatically become available to the user if they
|
||||||
// just sit on the page.
|
// just sit on the page.
|
||||||
useWebsocketEvent('install completed', () => {
|
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
|
// When we see the install started event immediately update the state to indicate such so that the
|
||||||
// screens automatically update.
|
// screens automatically update.
|
||||||
useWebsocketEvent('install started', () => {
|
useWebsocketEvent('install started', () => {
|
||||||
setServer({ ...server, isInstalling: true });
|
setServerFromState(s => ({ ...s, isInstalling: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { lazy, useEffect, useState } from 'react';
|
import React, { lazy, useEffect, useState } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
|
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 SuspenseSpinner from '@/components/elements/SuspenseSpinner';
|
||||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import ContentContainer from '@/components/elements/ContentContainer';
|
import ContentContainer from '@/components/elements/ContentContainer';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import StopOrKillButton from '@/components/server/StopOrKillButton';
|
import StopOrKillButton from '@/components/server/StopOrKillButton';
|
||||||
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
|
|
||||||
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||||
|
|
||||||
|
@ -23,10 +22,13 @@ export default () => {
|
||||||
const [ cpu, setCpu ] = useState(0);
|
const [ cpu, setCpu ] = useState(0);
|
||||||
const [ disk, setDisk ] = 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 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) => {
|
const statsListener = (data: string) => {
|
||||||
let stats: any = {};
|
let stats: any = {};
|
||||||
|
@ -57,16 +59,13 @@ export default () => {
|
||||||
};
|
};
|
||||||
}, [ instance, connected ]);
|
}, [ instance, connected ]);
|
||||||
|
|
||||||
const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited';
|
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
|
||||||
const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : 'Unlimited';
|
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock css={tw`flex`}>
|
<ServerContentBlock title={'Console'} css={tw`flex`}>
|
||||||
<Helmet>
|
|
||||||
<title> {server.name} | Console </title>
|
|
||||||
</Helmet>
|
|
||||||
<div css={tw`w-1/4`}>
|
<div css={tw`w-1/4`}>
|
||||||
<TitledGreyBox title={server.name} icon={faServer}>
|
<TitledGreyBox title={name} icon={faServer}>
|
||||||
<p css={tw`text-xs uppercase`}>
|
<p css={tw`text-xs uppercase`}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faCircle}
|
icon={faCircle}
|
||||||
|
@ -90,7 +89,7 @@ export default () => {
|
||||||
<span css={tw`text-neutral-500`}> / {disklimit}</span>
|
<span css={tw`text-neutral-500`}> / {disklimit}</span>
|
||||||
</p>
|
</p>
|
||||||
</TitledGreyBox>
|
</TitledGreyBox>
|
||||||
{!server.isInstalling ?
|
{!isInstalling ?
|
||||||
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
|
<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`}>
|
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
|
||||||
<Can action={'control.start'}>
|
<Can action={'control.start'}>
|
||||||
|
@ -143,6 +142,6 @@ export default () => {
|
||||||
<ChunkedStatGraphs/>
|
<ChunkedStatGraphs/>
|
||||||
</SuspenseSpinner>
|
</SuspenseSpinner>
|
||||||
</div>
|
</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 { ServerContext } from '@/state/server';
|
||||||
import { PowerAction } from '@/components/server/ServerConsole';
|
import { PowerAction } from '@/components/server/ServerConsole';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
import isEqual from 'react-fast-compare';
|
||||||
|
|
||||||
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
|
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
|
||||||
const [ clicked, setClicked ] = useState(false);
|
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 React, { useEffect } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
import getServerBackups from '@/api/server/backups/getServerBackups';
|
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
|
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import BackupRow from '@/components/server/backups/BackupRow';
|
import BackupRow from '@/components/server/backups/BackupRow';
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
import getServerBackups from '@/api/swr/getServerBackups';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { uuid, featureLimits, name: serverName } = useServer();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { data: backups, error, isValidating } = getServerBackups();
|
||||||
const [ loading, setLoading ] = useState(true);
|
|
||||||
|
|
||||||
const backups = ServerContext.useStoreState(state => state.backups.data);
|
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
|
||||||
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!error) {
|
||||||
clearFlashes('backups');
|
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 <Spinner size={'large'} centered/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock>
|
<ServerContentBlock title={'Backups'}>
|
||||||
<Helmet>
|
|
||||||
<title> {serverName} | Backups</title>
|
|
||||||
</Helmet>
|
|
||||||
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
|
||||||
{!backups.length ?
|
{!backups.items.length ?
|
||||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||||
There are no backups stored for this server.
|
There are no backups stored for this server.
|
||||||
</p>
|
</p>
|
||||||
:
|
:
|
||||||
<div>
|
<div>
|
||||||
{backups.map((backup, index) => <BackupRow
|
{backups.items.map((backup, index) => <BackupRow
|
||||||
key={backup.uuid}
|
key={backup.uuid}
|
||||||
backup={backup}
|
backup={backup}
|
||||||
css={index > 0 ? tw`mt-2` : undefined}
|
css={index > 0 ? tw`mt-2` : undefined}
|
||||||
/>)}
|
/>)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
{featureLimits.backups === 0 &&
|
{backupLimit === 0 &&
|
||||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||||
Backups cannot be created for this server.
|
Backups cannot be created for this server.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<Can action={'backup.create'}>
|
<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`}>
|
<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>
|
</p>
|
||||||
}
|
}
|
||||||
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
|
{backupLimit > 0 && backupLimit !== backups.items.length &&
|
||||||
<div css={tw`mt-6 flex justify-end`}>
|
<div css={tw`mt-6 flex justify-end`}>
|
||||||
<CreateBackupButton/>
|
<CreateBackupButton/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</Can>
|
</Can>
|
||||||
</PageContentBlock>
|
</ServerContentBlock>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,31 +1,30 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
|
||||||
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
import getServerBackups from '@/api/swr/getServerBackups';
|
||||||
|
import { ServerBackup } from '@/api/server/types';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
backup: ServerBackup;
|
backup: ServerBackup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ backup }: Props) => {
|
export default ({ backup }: Props) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const [ loading, setLoading ] = useState(false);
|
const [ loading, setLoading ] = useState(false);
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
const [ deleteVisible, setDeleteVisible ] = useState(false);
|
const [ deleteVisible, setDeleteVisible ] = useState(false);
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup);
|
const { mutate } = getServerBackups();
|
||||||
|
|
||||||
const doDownload = () => {
|
const doDownload = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -37,7 +36,7 @@ export default ({ backup }: Props) => {
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
clearAndAddHttpError({ key: 'backups', error });
|
||||||
})
|
})
|
||||||
.then(() => setLoading(false));
|
.then(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
@ -46,10 +45,15 @@ export default ({ backup }: Props) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
clearFlashes('backups');
|
clearFlashes('backups');
|
||||||
deleteBackup(uuid, backup.uuid)
|
deleteBackup(uuid, backup.uuid)
|
||||||
.then(() => removeBackup(backup.uuid))
|
.then(() => {
|
||||||
|
mutate(data => ({
|
||||||
|
...data,
|
||||||
|
items: data.items.filter(b => b.uuid !== backup.uuid),
|
||||||
|
}), false);
|
||||||
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
clearAndAddHttpError({ key: 'backups', error });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setDeleteVisible(false);
|
setDeleteVisible(false);
|
||||||
});
|
});
|
||||||
|
@ -62,22 +66,21 @@ export default ({ backup }: Props) => {
|
||||||
appear
|
appear
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onDismissed={() => setVisible(false)}
|
onDismissed={() => setVisible(false)}
|
||||||
checksum={backup.sha256Hash}
|
checksum={backup.checksum}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{deleteVisible &&
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
|
visible={deleteVisible}
|
||||||
title={'Delete this backup?'}
|
title={'Delete this backup?'}
|
||||||
buttonText={'Yes, delete backup'}
|
buttonText={'Yes, delete backup'}
|
||||||
onConfirmed={() => doDeletion()}
|
onConfirmed={() => doDeletion()}
|
||||||
visible={deleteVisible}
|
onModalDismissed={() => setDeleteVisible(false)}
|
||||||
onDismissed={() => setDeleteVisible(false)}
|
|
||||||
>
|
>
|
||||||
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
|
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
|
||||||
be recovered once deleted.
|
be recovered once deleted.
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
}
|
|
||||||
<SpinnerOverlay visible={loading} fixed/>
|
<SpinnerOverlay visible={loading} fixed/>
|
||||||
|
{backup.isSuccessful ?
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
renderToggle={onClick => (
|
renderToggle={onClick => (
|
||||||
<button
|
<button
|
||||||
|
@ -107,6 +110,14 @@ export default ({ backup }: Props) => {
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu>
|
</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 React from 'react';
|
||||||
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
|
@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner';
|
||||||
import { bytesToHuman } from '@/helpers';
|
import { bytesToHuman } from '@/helpers';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
|
import getServerBackups from '@/api/swr/getServerBackups';
|
||||||
|
import { ServerBackup } from '@/api/server/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
backup: ServerBackup;
|
backup: ServerBackup;
|
||||||
|
@ -18,17 +18,22 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ backup, className }: Props) => {
|
export default ({ backup, className }: Props) => {
|
||||||
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
|
const { mutate } = getServerBackups();
|
||||||
|
|
||||||
useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
|
useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
appendBackup({
|
|
||||||
...backup,
|
mutate(data => ({
|
||||||
sha256Hash: parsed.sha256_hash || '',
|
...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,
|
bytes: parsed.file_size || 0,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
});
|
})),
|
||||||
|
}), false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
|
@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`flex-1`}>
|
<div css={tw`flex-1`}>
|
||||||
<p css={tw`text-sm mb-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.name}
|
||||||
{backup.completedAt &&
|
{(backup.completedAt && backup.isSuccessful) &&
|
||||||
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
|
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -6,7 +6,7 @@ const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum:
|
||||||
<Modal {...props}>
|
<Modal {...props}>
|
||||||
<h3 css={tw`mb-6`}>Verify file checksum</h3>
|
<h3 css={tw`mb-6`}>Verify file checksum</h3>
|
||||||
<p css={tw`text-sm`}>
|
<p css={tw`text-sm`}>
|
||||||
The SHA256 checksum of this file is:
|
The checksum of this file is:
|
||||||
</p>
|
</p>
|
||||||
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
||||||
<code css={tw`block font-mono`}>{checksum}</code>
|
<code css={tw`block font-mono`}>{checksum}</code>
|
||||||
|
|
|
@ -5,14 +5,13 @@ import { object, string } from 'yup';
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import createServerBackup from '@/api/server/backups/createServerBackup';
|
import createServerBackup from '@/api/server/backups/createServerBackup';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { Textarea } from '@/components/elements/Input';
|
import { Textarea } from '@/components/elements/Input';
|
||||||
|
import getServerBackups from '@/api/swr/getServerBackups';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -59,11 +58,10 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
const { mutate } = getServerBackups();
|
||||||
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearFlashes('backups:create');
|
clearFlashes('backups:create');
|
||||||
|
@ -73,12 +71,11 @@ export default () => {
|
||||||
clearFlashes('backups:create');
|
clearFlashes('backups:create');
|
||||||
createServerBackup(uuid, name, ignored)
|
createServerBackup(uuid, name, ignored)
|
||||||
.then(backup => {
|
.then(backup => {
|
||||||
appendBackup(backup);
|
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(error);
|
clearAndAddHttpError({ key: 'backups:create', error });
|
||||||
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { ServerContext } from '@/state/server';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
|
||||||
|
@ -29,7 +28,7 @@ const schema = object().shape({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { addError, clearFlashes } = useFlash();
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 Modal from '@/components/elements/Modal';
|
||||||
import { Form, Formik, FormikHelpers } from 'formik';
|
import { Form, Formik, FormikHelpers } from 'formik';
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
|
@ -12,7 +12,6 @@ import { httpErrorToHuman } from '@/api/http';
|
||||||
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
|
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
import { ServerDatabase } from '@/api/server/getServerDatabases';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
@ -26,7 +25,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ database, className }: Props) => {
|
export default ({ database, className }: Props) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { addError, clearFlashes } = useFlash();
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
const [ connectionVisible, setConnectionVisible ] = useState(false);
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import getServerDatabases from '@/api/server/getServerDatabases';
|
import getServerDatabases from '@/api/server/getServerDatabases';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
|
@ -9,17 +8,19 @@ import Spinner from '@/components/elements/Spinner';
|
||||||
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
|
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Fade from '@/components/elements/Fade';
|
import Fade from '@/components/elements/Fade';
|
||||||
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
|
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
|
||||||
|
|
||||||
export default () => {
|
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 { addError, clearFlashes } = useFlash();
|
||||||
const [ loading, setLoading ] = useState(true);
|
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);
|
const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -36,10 +37,7 @@ export default () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock>
|
<ServerContentBlock title={'Databases'}>
|
||||||
<Helmet>
|
|
||||||
<title> {serverName} | Databases </title>
|
|
||||||
</Helmet>
|
|
||||||
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
|
||||||
{(!databases.length && loading) ?
|
{(!databases.length && loading) ?
|
||||||
<Spinner size={'large'} centered/>
|
<Spinner size={'large'} centered/>
|
||||||
|
@ -56,7 +54,7 @@ export default () => {
|
||||||
))
|
))
|
||||||
:
|
:
|
||||||
<p css={tw`text-center text-sm text-neutral-400`}>
|
<p css={tw`text-center text-sm text-neutral-400`}>
|
||||||
{featureLimits.databases > 0 ?
|
{databaseLimit > 0 ?
|
||||||
'It looks like you have no databases.'
|
'It looks like you have no databases.'
|
||||||
:
|
:
|
||||||
'Databases cannot be created for this server.'
|
'Databases cannot be created for this server.'
|
||||||
|
@ -64,13 +62,13 @@ export default () => {
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
<Can action={'database.create'}>
|
<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`}>
|
<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.
|
server.
|
||||||
</p>
|
</p>
|
||||||
}
|
}
|
||||||
{featureLimits.databases > 0 && featureLimits.databases !== databases.length &&
|
{databaseLimit > 0 && databaseLimit !== databases.length &&
|
||||||
<div css={tw`mt-6 flex justify-end`}>
|
<div css={tw`mt-6 flex justify-end`}>
|
||||||
<CreateDatabaseButton/>
|
<CreateDatabaseButton/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -79,6 +77,6 @@ export default () => {
|
||||||
</>
|
</>
|
||||||
</Fade>
|
</Fade>
|
||||||
}
|
}
|
||||||
</PageContentBlock>
|
</ServerContentBlock>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,6 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import copyFile from '@/api/server/files/copyFile';
|
import copyFile from '@/api/server/files/copyFile';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
|
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||||
|
@ -56,7 +55,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
|
||||||
const [ showSpinner, setShowSpinner ] = useState(false);
|
const [ showSpinner, setShowSpinner ] = useState(false);
|
||||||
const [ modal, setModal ] = useState<ModalType | null>(null);
|
const [ modal, setModal ] = useState<ModalType | null>(null);
|
||||||
|
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { mutate } = useFileManagerSwr();
|
const { mutate } = useFileManagerSwr();
|
||||||
const { clearAndAddHttpError, clearFlashes } = useFlash();
|
const { clearAndAddHttpError, clearFlashes } = useFlash();
|
||||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import React, { lazy, useEffect, useState } from 'react';
|
import React, { lazy, useEffect, useState } from 'react';
|
||||||
import { ServerContext } from '@/state/server';
|
|
||||||
import getFileContents from '@/api/server/files/getFileContents';
|
import getFileContents from '@/api/server/files/getFileContents';
|
||||||
import { Actions, useStoreActions } from 'easy-peasy';
|
|
||||||
import { ApplicationStore } from '@/state';
|
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import saveFileContents from '@/api/server/files/saveFileContents';
|
import saveFileContents from '@/api/server/files/saveFileContents';
|
||||||
|
@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
import ServerError from '@/components/screens/ServerError';
|
import ServerError from '@/components/screens/ServerError';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
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'));
|
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
|
||||||
|
|
||||||
|
@ -24,17 +25,20 @@ export default () => {
|
||||||
const [ loading, setLoading ] = useState(action === 'edit');
|
const [ loading, setLoading ] = useState(action === 'edit');
|
||||||
const [ content, setContent ] = useState('');
|
const [ content, setContent ] = useState('');
|
||||||
const [ modalVisible, setModalVisible ] = useState(false);
|
const [ modalVisible, setModalVisible ] = useState(false);
|
||||||
|
const [ mode, setMode ] = useState('plain_text');
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
|
|
||||||
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
|
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||||
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
|
const { addError, clearFlashes } = useFlash();
|
||||||
|
|
||||||
let fetchFileContent: null | (() => Promise<string>) = null;
|
let fetchFileContent: null | (() => Promise<string>) = null;
|
||||||
|
|
||||||
if (action !== 'new') {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (action === 'new') return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
getFileContents(uuid, hash.replace(/^#/, ''))
|
getFileContents(uuid, hash.replace(/^#/, ''))
|
||||||
|
@ -44,8 +48,7 @@ export default () => {
|
||||||
setError(httpErrorToHuman(error));
|
setError(httpErrorToHuman(error));
|
||||||
})
|
})
|
||||||
.then(() => setLoading(false));
|
.then(() => setLoading(false));
|
||||||
}, [ uuid, hash ]);
|
}, [ action, uuid, hash ]);
|
||||||
}
|
|
||||||
|
|
||||||
const save = (name?: string) => {
|
const save = (name?: string) => {
|
||||||
if (!fetchFileContent) {
|
if (!fetchFileContent) {
|
||||||
|
@ -75,10 +78,7 @@ export default () => {
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<ServerError
|
<ServerError message={error} onBack={() => history.goBack()}/>
|
||||||
message={error}
|
|
||||||
onBack={() => history.goBack()}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,15 +109,24 @@ export default () => {
|
||||||
<div css={tw`relative`}>
|
<div css={tw`relative`}>
|
||||||
<SpinnerOverlay visible={loading}/>
|
<SpinnerOverlay visible={loading}/>
|
||||||
<LazyAceEditor
|
<LazyAceEditor
|
||||||
initialModePath={hash.replace(/^#/, '') || 'plain_text'}
|
mode={mode}
|
||||||
|
filename={hash.replace(/^#/, '')}
|
||||||
|
onModeChanged={setMode}
|
||||||
initialContent={content}
|
initialContent={content}
|
||||||
fetchContent={value => {
|
fetchContent={value => {
|
||||||
fetchFileContent = value;
|
fetchFileContent = value;
|
||||||
}}
|
}}
|
||||||
onContentSaved={() => save()}
|
onContentSaved={save}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`flex justify-end mt-4`}>
|
<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' ?
|
{action === 'edit' ?
|
||||||
<Can action={'file.update'}>
|
<Can action={'file.update'}>
|
||||||
<Button onClick={() => save()}>
|
<Button onClick={() => save()}>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink, useRouteMatch } from 'react-router-dom';
|
||||||
import { cleanDirectoryPath } from '@/helpers';
|
import { cleanDirectoryPath } from '@/helpers';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox';
|
import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox';
|
||||||
|
@ -13,6 +13,7 @@ interface Props {
|
||||||
|
|
||||||
export default ({ withinFileEditor, isNewFile }: Props) => {
|
export default ({ withinFileEditor, isNewFile }: Props) => {
|
||||||
const [ file, setFile ] = useState<string | null>(null);
|
const [ file, setFile ] = useState<string | null>(null);
|
||||||
|
const { params } = useRouteMatch<Record<string, string>>();
|
||||||
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
const directory = ServerContext.useStoreState(state => state.files.directory);
|
||||||
|
|
||||||
|
@ -44,7 +45,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
|
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
|
||||||
{!!(files && files.length) &&
|
{(files && files.length > 0 && !params?.action) &&
|
||||||
<FileActionCheckbox
|
<FileActionCheckbox
|
||||||
type={'checkbox'}
|
type={'checkbox'}
|
||||||
css={tw`mx-4`}
|
css={tw`mx-4`}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
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 NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import ServerError from '@/components/screens/ServerError';
|
import ServerError from '@/components/screens/ServerError';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||||
import MassActionsBar from '@/components/server/files/MassActionsBar';
|
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[] => {
|
const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||||
return files.sort((a, b) => a.name.localeCompare(b.name))
|
return files.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
@ -24,7 +23,7 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { id, name: serverName } = useServer();
|
const id = ServerContext.useStoreState(state => state.server.data!.id);
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const { data: files, error, mutate } = useFileManagerSwr();
|
const { data: files, error, mutate } = useFileManagerSwr();
|
||||||
|
|
||||||
|
@ -43,10 +42,7 @@ export default () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock showFlashKey={'files'}>
|
<ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
|
||||||
<Helmet>
|
|
||||||
<title> {serverName} | File Manager </title>
|
|
||||||
</Helmet>
|
|
||||||
<FileManagerBreadcrumbs/>
|
<FileManagerBreadcrumbs/>
|
||||||
{
|
{
|
||||||
!files ?
|
!files ?
|
||||||
|
@ -80,6 +76,7 @@ export default () => {
|
||||||
<Can action={'file.create'}>
|
<Can action={'file.create'}>
|
||||||
<div css={tw`flex justify-end mt-8`}>
|
<div css={tw`flex justify-end mt-8`}>
|
||||||
<NewDirectoryButton/>
|
<NewDirectoryButton/>
|
||||||
|
<UploadButton/>
|
||||||
<Button
|
<Button
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
as={Link}
|
as={Link}
|
||||||
|
@ -91,6 +88,6 @@ export default () => {
|
||||||
</Can>
|
</Can>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</PageContentBlock>
|
</ServerContentBlock>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -8,14 +8,14 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import compressFiles from '@/api/server/files/compressFiles';
|
import compressFiles from '@/api/server/files/compressFiles';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
import deleteFiles from '@/api/server/files/deleteFiles';
|
import deleteFiles from '@/api/server/files/deleteFiles';
|
||||||
import RenameFileModal from '@/components/server/files/RenameFileModal';
|
import RenameFileModal from '@/components/server/files/RenameFileModal';
|
||||||
|
|
||||||
const MassActionsBar = () => {
|
const MassActionsBar = () => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
|
|
||||||
const { mutate } = useFileManagerSwr();
|
const { mutate } = useFileManagerSwr();
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const [ loading, setLoading ] = useState(false);
|
const [ loading, setLoading ] = useState(false);
|
||||||
|
@ -72,7 +72,7 @@ const MassActionsBar = () => {
|
||||||
title={'Delete these files?'}
|
title={'Delete these files?'}
|
||||||
buttonText={'Yes, Delete Files'}
|
buttonText={'Yes, Delete Files'}
|
||||||
onConfirmed={onClickConfirmDeletion}
|
onConfirmed={onClickConfirmDeletion}
|
||||||
onDismissed={() => setShowConfirm(false)}
|
onModalDismissed={() => setShowConfirm(false)}
|
||||||
>
|
>
|
||||||
Deleting files is a permanent operation, you cannot undo this action.
|
Deleting files is a permanent operation, you cannot undo this action.
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { object, string } from 'yup';
|
||||||
import createDirectory from '@/api/server/files/createDirectory';
|
import createDirectory from '@/api/server/files/createDirectory';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import { FileObject } from '@/api/server/files/loadDirectory';
|
import { FileObject } from '@/api/server/files/loadDirectory';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||||
|
@ -36,7 +35,7 @@ const generateDirectoryData = (name: string): FileObject => ({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { clearAndAddHttpError } = useFlash();
|
const { clearAndAddHttpError } = useFlash();
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import renameFiles from '@/api/server/files/renameFiles';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
|
||||||
|
@ -18,7 +17,7 @@ interface FormikValues {
|
||||||
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
|
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
|
||||||
|
|
||||||
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { mutate } = useFileManagerSwr();
|
const { mutate } = useFileManagerSwr();
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const directory = ServerContext.useStoreState(state => state.files.directory);
|
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 React, { useEffect, useState } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
|
||||||
import styled from 'styled-components/macro';
|
import styled from 'styled-components/macro';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import getServerAllocations from '@/api/server/network/getServerAllocations';
|
import getServerAllocations from '@/api/server/network/getServerAllocations';
|
||||||
import { Allocation } from '@/api/server/getServer';
|
import { Allocation } from '@/api/server/getServer';
|
||||||
|
@ -19,12 +16,17 @@ import { Textarea } from '@/components/elements/Input';
|
||||||
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
|
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
|
||||||
import { debounce } from 'debounce';
|
import { debounce } from 'debounce';
|
||||||
import InputSpinner from '@/components/elements/InputSpinner';
|
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 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 Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
|
||||||
|
|
||||||
const NetworkContainer = () => {
|
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 { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const [ loading, setLoading ] = useState<false | number>(false);
|
const [ loading, setLoading ] = useState<false | number>(false);
|
||||||
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
|
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
|
||||||
|
@ -61,10 +63,7 @@ const NetworkContainer = () => {
|
||||||
}, [ error ]);
|
}, [ error ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock showFlashKey={'server:network'}>
|
<ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
|
||||||
<Helmet>
|
|
||||||
<title> {serverName} | Network </title>
|
|
||||||
</Helmet>
|
|
||||||
{!data ?
|
{!data ?
|
||||||
<Spinner size={'large'} centered/>
|
<Spinner size={'large'} centered/>
|
||||||
:
|
:
|
||||||
|
@ -112,7 +111,7 @@ const NetworkContainer = () => {
|
||||||
</GreyRowBox>
|
</GreyRowBox>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</PageContentBlock>
|
</ServerContentBlock>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
showSpinnerOverlay={isLoading}
|
visible={visible}
|
||||||
title={'Delete schedule?'}
|
title={'Delete schedule?'}
|
||||||
buttonText={'Yes, delete schedule'}
|
buttonText={'Yes, delete schedule'}
|
||||||
onConfirmed={onDelete}
|
onConfirmed={onDelete}
|
||||||
visible={visible}
|
showSpinnerOverlay={isLoading}
|
||||||
onDismissed={() => setVisible(false)}
|
onModalDismissed={() => setVisible(false)}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
|
Are you sure you want to delete this schedule? All tasks will be removed and any running processes
|
||||||
will be terminated.
|
will be terminated.
|
||||||
|
|
|
@ -8,7 +8,6 @@ import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedul
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
@ -75,7 +74,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ({ schedule, visible, ...props }: Props) => {
|
export default ({ schedule, visible, ...props }: Props) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { addError, clearFlashes } = useFlash();
|
const { addError, clearFlashes } = useFlash();
|
||||||
const [ modalVisible, setModalVisible ] = useState(visible);
|
const [ modalVisible, setModalVisible ] = useState(visible);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import getServerSchedules from '@/api/server/schedules/getServerSchedules';
|
import getServerSchedules from '@/api/server/schedules/getServerSchedules';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
@ -9,15 +8,14 @@ import ScheduleRow from '@/components/server/schedules/ScheduleRow';
|
||||||
import { httpErrorToHuman } from '@/api/http';
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import GreyRowBox from '@/components/elements/GreyRowBox';
|
import GreyRowBox from '@/components/elements/GreyRowBox';
|
||||||
import Button from '@/components/elements/Button';
|
import Button from '@/components/elements/Button';
|
||||||
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
|
|
||||||
export default ({ match, history }: RouteComponentProps) => {
|
export default ({ match, history }: RouteComponentProps) => {
|
||||||
const { uuid, name: serverName } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { clearFlashes, addError } = useFlash();
|
const { clearFlashes, addError } = useFlash();
|
||||||
const [ loading, setLoading ] = useState(true);
|
const [ loading, setLoading ] = useState(true);
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
@ -37,10 +35,7 @@ export default ({ match, history }: RouteComponentProps) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentBlock>
|
<ServerContentBlock title={'Schedules'}>
|
||||||
<Helmet>
|
|
||||||
<title> {serverName} | Schedules </title>
|
|
||||||
</Helmet>
|
|
||||||
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
|
||||||
{(!schedules.length && loading) ?
|
{(!schedules.length && loading) ?
|
||||||
<Spinner size={'large'} centered/>
|
<Spinner size={'large'} centered/>
|
||||||
|
@ -77,6 +72,6 @@ export default ({ match, history }: RouteComponentProps) => {
|
||||||
</Can>
|
</Can>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</PageContentBlock>
|
</ServerContentBlock>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,6 @@ import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
|
||||||
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
|
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
|
||||||
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
|
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
import PageContentBlock from '@/components/elements/PageContentBlock';
|
||||||
|
@ -28,7 +27,9 @@ interface State {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, 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 { clearFlashes, addError } = useFlash();
|
||||||
const [ isLoading, setIsLoading ] = useState(true);
|
const [ isLoading, setIsLoading ] = useState(true);
|
||||||
const [ showEditModal, setShowEditModal ] = useState(false);
|
const [ showEditModal, setShowEditModal ] = useState(false);
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { httpErrorToHuman } from '@/api/http';
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
|
@ -32,7 +31,7 @@ const getActionDetails = (action: string): [ string, any ] => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ({ schedule, task }: Props) => {
|
export default ({ schedule, task }: Props) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { clearFlashes, addError } = useFlash();
|
const { clearFlashes, addError } = useFlash();
|
||||||
const [ visible, setVisible ] = useState(false);
|
const [ visible, setVisible ] = useState(false);
|
||||||
const [ isLoading, setIsLoading ] = useState(false);
|
const [ isLoading, setIsLoading ] = useState(false);
|
||||||
|
@ -69,7 +68,7 @@ export default ({ schedule, task }: Props) => {
|
||||||
buttonText={'Delete Task'}
|
buttonText={'Delete Task'}
|
||||||
onConfirmed={onConfirmDeletion}
|
onConfirmed={onConfirmDeletion}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onDismissed={() => setVisible(false)}
|
onModalDismissed={() => setVisible(false)}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete this task? This action cannot be undone.
|
Are you sure you want to delete this task? This action cannot be undone.
|
||||||
</ConfirmationModal>
|
</ConfirmationModal>
|
||||||
|
|
|
@ -9,7 +9,6 @@ import Field from '@/components/elements/Field';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import { number, object, string } from 'yup';
|
import { number, object, string } from 'yup';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import useServer from '@/plugins/useServer';
|
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Label from '@/components/elements/Label';
|
import Label from '@/components/elements/Label';
|
||||||
|
@ -108,7 +107,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ({ task, schedule, onDismissed }: Props) => {
|
export default ({ task, schedule, onDismissed }: Props) => {
|
||||||
const { uuid } = useServer();
|
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
|
||||||
const { clearFlashes, addError } = useFlash();
|
const { clearFlashes, addError } = useFlash();
|
||||||
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
|
||||||
|
|
||||||
|
|
|
@ -46,10 +46,10 @@ export default () => {
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
title={'Confirm server reinstallation'}
|
title={'Confirm server reinstallation'}
|
||||||
buttonText={'Yes, reinstall server'}
|
buttonText={'Yes, reinstall server'}
|
||||||
onConfirmed={() => reinstall()}
|
onConfirmed={reinstall}
|
||||||
showSpinnerOverlay={isSubmitting}
|
showSpinnerOverlay={isSubmitting}
|
||||||
visible={modalVisible}
|
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
|
Your server will be stopped and some files may be deleted or modified during this process, are you sure
|
||||||
you wish to continue?
|
you wish to continue?
|
||||||
|
|
|
@ -1,29 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
|
||||||
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
import TitledGreyBox from '@/components/elements/TitledGreyBox';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import { useStoreState } from 'easy-peasy';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
|
||||||
import { UserData } from '@/state/user';
|
|
||||||
import RenameServerBox from '@/components/server/settings/RenameServerBox';
|
import RenameServerBox from '@/components/server/settings/RenameServerBox';
|
||||||
import FlashMessageRender from '@/components/FlashMessageRender';
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
|
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
|
||||||
import PageContentBlock from '@/components/elements/PageContentBlock';
|
|
||||||
import tw from 'twin.macro';
|
import tw from 'twin.macro';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import Label from '@/components/elements/Label';
|
import Label from '@/components/elements/Label';
|
||||||
import { LinkButton } from '@/components/elements/Button';
|
import { LinkButton } from '@/components/elements/Button';
|
||||||
|
import ServerContentBlock from '@/components/elements/ServerContentBlock';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!);
|
const username = useStoreState(state => state.user.data!.username);
|
||||||
const server = ServerContext.useStoreState(state => state.server.data!);
|
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 (
|
return (
|
||||||
<PageContentBlock>
|
<ServerContentBlock title={'Settings'}>
|
||||||
<Helmet>
|
|
||||||
<title> {server.name} | Settings </title>
|
|
||||||
</Helmet>
|
|
||||||
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
|
||||||
<div css={tw`md:flex`}>
|
<div css={tw`md:flex`}>
|
||||||
<div css={tw`w-full md:flex-1 md:mr-10`}>
|
<div css={tw`w-full md:flex-1 md:mr-10`}>
|
||||||
|
@ -33,7 +29,7 @@ export default () => {
|
||||||
<Label>Server Address</Label>
|
<Label>Server Address</Label>
|
||||||
<Input
|
<Input
|
||||||
type={'text'}
|
type={'text'}
|
||||||
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
value={`sftp://${sftpIp}:${sftpPort}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -41,7 +37,7 @@ export default () => {
|
||||||
<Label>Username</Label>
|
<Label>Username</Label>
|
||||||
<Input
|
<Input
|
||||||
type={'text'}
|
type={'text'}
|
||||||
value={`${user.username}.${server.id}`}
|
value={`${username}.${id}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,7 +52,7 @@ export default () => {
|
||||||
<div css={tw`ml-4`}>
|
<div css={tw`ml-4`}>
|
||||||
<LinkButton
|
<LinkButton
|
||||||
isSecondary
|
isSecondary
|
||||||
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`}
|
href={`sftp://${username}.${id}@${sftpIp}:${sftpPort}`}
|
||||||
>
|
>
|
||||||
Launch SFTP
|
Launch SFTP
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
@ -76,6 +72,6 @@ export default () => {
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</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