Merge branch 'develop' into feature/server-transfers-actually
This commit is contained in:
commit
fd4de9168a
49 changed files with 1000 additions and 37 deletions
77
app/Http/Controllers/Api/Client/Servers/BackupController.php
Normal file
77
app/Http/Controllers/Api/Client/Servers/BackupController.php
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Services\Backups\InitiateBackupService;
|
||||||
|
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
|
||||||
|
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
|
||||||
|
|
||||||
|
class BackupController extends ClientApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var \Pterodactyl\Services\Backups\InitiateBackupService
|
||||||
|
*/
|
||||||
|
private $initiateBackupService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackupController constructor.
|
||||||
|
*
|
||||||
|
* @param \Pterodactyl\Services\Backups\InitiateBackupService $initiateBackupService
|
||||||
|
*/
|
||||||
|
public function __construct(InitiateBackupService $initiateBackupService)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
|
||||||
|
$this->initiateBackupService = $initiateBackupService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all of the backups for a given server instance in a paginated
|
||||||
|
* result set.
|
||||||
|
*
|
||||||
|
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest $request
|
||||||
|
* @param \Pterodactyl\Models\Server $server
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function index(GetBackupsRequest $request, Server $server)
|
||||||
|
{
|
||||||
|
return $this->fractal->collection($server->backups()->paginate(20))
|
||||||
|
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the backup process for a server.
|
||||||
|
*
|
||||||
|
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest $request
|
||||||
|
* @param \Pterodactyl\Models\Server $server
|
||||||
|
* @return array
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function store(StoreBackupRequest $request, Server $server)
|
||||||
|
{
|
||||||
|
$backup = $this->initiateBackupService
|
||||||
|
->setIgnoredFiles($request->input('ignored'))
|
||||||
|
->handle($server, $request->input('name'));
|
||||||
|
|
||||||
|
return $this->fractal->item($backup)
|
||||||
|
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
|
|
||||||
|
class GetBackupsRequest extends ClientApiRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function permission()
|
||||||
|
{
|
||||||
|
return Permission::ACTION_BACKUP_READ;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
|
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||||
|
|
||||||
|
class StoreBackupRequest extends ClientApiRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function permission()
|
||||||
|
{
|
||||||
|
return Permission::ACTION_BACKUP_CREATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'nullable|string|max:255',
|
||||||
|
'ignore' => 'nullable|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,7 @@ namespace Pterodactyl\Models;
|
||||||
* @property \Pterodactyl\Models\Server|null $server
|
* @property \Pterodactyl\Models\Server|null $server
|
||||||
* @property \Pterodactyl\Models\Node $node
|
* @property \Pterodactyl\Models\Node $node
|
||||||
*/
|
*/
|
||||||
class Allocation extends Validable
|
class Allocation extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
@ -75,7 +75,7 @@ class Allocation extends Validable
|
||||||
/**
|
/**
|
||||||
* Accessor to automatically provide the IP alias if defined.
|
* Accessor to automatically provide the IP alias if defined.
|
||||||
*
|
*
|
||||||
* @param null|string $value
|
* @param string|null $value
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public function getAliasAttribute($value)
|
public function getAliasAttribute($value)
|
||||||
|
@ -86,7 +86,7 @@ class Allocation extends Validable
|
||||||
/**
|
/**
|
||||||
* Accessor to quickly determine if this allocation has an alias.
|
* Accessor to quickly determine if this allocation has an alias.
|
||||||
*
|
*
|
||||||
* @param null|string $value
|
* @param string|null $value
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function getHasAliasAttribute($value)
|
public function getHasAliasAttribute($value)
|
||||||
|
|
|
@ -16,7 +16,7 @@ use Pterodactyl\Services\Acl\Api\AdminAcl;
|
||||||
* @property \Carbon\Carbon $created_at
|
* @property \Carbon\Carbon $created_at
|
||||||
* @property \Carbon\Carbon $updated_at
|
* @property \Carbon\Carbon $updated_at
|
||||||
*/
|
*/
|
||||||
class ApiKey extends Validable
|
class ApiKey extends Model
|
||||||
{
|
{
|
||||||
const RESOURCE_NAME = 'api_key';
|
const RESOURCE_NAME = 'api_key';
|
||||||
|
|
||||||
|
|
82
app/Models/Backup.php
Normal file
82
app/Models/Backup.php
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property int $server_id
|
||||||
|
* @property int $uuid
|
||||||
|
* @property string $name
|
||||||
|
* @property string $ignored_files
|
||||||
|
* @property string $disk
|
||||||
|
* @property string|null $sha256_hash
|
||||||
|
* @property int $bytes
|
||||||
|
* @property \Carbon\CarbonImmutable|null $completed_at
|
||||||
|
* @property \Carbon\CarbonImmutable $created_at
|
||||||
|
* @property \Carbon\CarbonImmutable $updated_at
|
||||||
|
* @property \Carbon\CarbonImmutable|null $deleted_at
|
||||||
|
*
|
||||||
|
* @property \Pterodactyl\Models\Server $server
|
||||||
|
*/
|
||||||
|
class Backup extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
const RESOURCE_NAME = 'backup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $table = 'backups';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $immutableDates = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'id' => 'int',
|
||||||
|
'bytes' => 'int',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $dates = [
|
||||||
|
'completed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $attributes = [
|
||||||
|
'sha256_hash' => null,
|
||||||
|
'bytes' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public static $validationRules = [
|
||||||
|
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||||
|
'uuid' => 'required|uuid',
|
||||||
|
'name' => 'required|string',
|
||||||
|
'ignored_files' => 'string',
|
||||||
|
'disk' => 'required|string',
|
||||||
|
'sha256_hash' => 'nullable|string',
|
||||||
|
'bytes' => 'numeric',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
|
*/
|
||||||
|
public function server()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Server::class);
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
|
||||||
|
|
||||||
use Znck\Eloquent\Traits\BelongsToThrough;
|
use Znck\Eloquent\Traits\BelongsToThrough;
|
||||||
|
|
||||||
class DaemonKey extends Validable
|
class DaemonKey extends Model
|
||||||
{
|
{
|
||||||
use BelongsToThrough;
|
use BelongsToThrough;
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
class Database extends Validable
|
class Database extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
class DatabaseHost extends Validable
|
class DatabaseHost extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -39,7 +39,7 @@ namespace Pterodactyl\Models;
|
||||||
* @property \Pterodactyl\Models\Egg|null $scriptFrom
|
* @property \Pterodactyl\Models\Egg|null $scriptFrom
|
||||||
* @property \Pterodactyl\Models\Egg|null $configFrom
|
* @property \Pterodactyl\Models\Egg|null $configFrom
|
||||||
*/
|
*/
|
||||||
class Egg extends Validable
|
class Egg extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
class EggVariable extends Validable
|
class EggVariable extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
class Location extends Validable
|
class Location extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -5,11 +5,18 @@ namespace Pterodactyl\Models;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Container\Container;
|
use Illuminate\Container\Container;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Contracts\Validation\Factory;
|
use Illuminate\Contracts\Validation\Factory;
|
||||||
|
use Illuminate\Database\Eloquent\Model as IlluminateModel;
|
||||||
|
|
||||||
abstract class Validable extends Model
|
abstract class Model extends IlluminateModel
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Set to true to return immutable Carbon date instances from the model.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $immutableDates = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the model should undergo data validation before it is saved
|
* Determines if the model should undergo data validation before it is saved
|
||||||
* to the database.
|
* to the database.
|
||||||
|
@ -47,7 +54,7 @@ abstract class Validable extends Model
|
||||||
|
|
||||||
static::$validatorFactory = Container::getInstance()->make(Factory::class);
|
static::$validatorFactory = Container::getInstance()->make(Factory::class);
|
||||||
|
|
||||||
static::saving(function (Validable $model) {
|
static::saving(function (Model $model) {
|
||||||
return $model->validate();
|
return $model->validate();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -148,4 +155,19 @@ abstract class Validable extends Model
|
||||||
)
|
)
|
||||||
)->passes();
|
)->passes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a timestamp as DateTime object.
|
||||||
|
*
|
||||||
|
* @param mixed $value
|
||||||
|
* @return \Illuminate\Support\Carbon|\Carbon\CarbonImmutable
|
||||||
|
*/
|
||||||
|
protected function asDateTime($value)
|
||||||
|
{
|
||||||
|
if (! $this->immutableDates) {
|
||||||
|
return parent::asDateTime($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::asDateTime($value)->toImmutable();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -15,7 +15,7 @@ namespace Pterodactyl\Models;
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Pack[] $packs
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Pack[] $packs
|
||||||
*/
|
*/
|
||||||
class Nest extends Validable
|
class Nest extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -32,9 +32,10 @@ use Pterodactyl\Models\Traits\Searchable;
|
||||||
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
|
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
|
||||||
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
|
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
|
||||||
*/
|
*/
|
||||||
class Node extends Validable
|
class Node extends Model
|
||||||
{
|
{
|
||||||
use Notifiable, Searchable;
|
use Notifiable;
|
||||||
|
use Searchable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -20,7 +20,7 @@ use Pterodactyl\Models\Traits\Searchable;
|
||||||
* @property \Pterodactyl\Models\Egg|null $egg
|
* @property \Pterodactyl\Models\Egg|null $egg
|
||||||
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
|
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
|
||||||
*/
|
*/
|
||||||
class Pack extends Validable
|
class Pack extends Model
|
||||||
{
|
{
|
||||||
use Searchable;
|
use Searchable;
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class Permission extends Validable
|
class Permission extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
@ -37,6 +37,12 @@ class Permission extends Validable
|
||||||
const ACTION_USER_UPDATE = 'user.update';
|
const ACTION_USER_UPDATE = 'user.update';
|
||||||
const ACTION_USER_DELETE = 'user.delete';
|
const ACTION_USER_DELETE = 'user.delete';
|
||||||
|
|
||||||
|
const ACTION_BACKUP_READ = 'backup.read';
|
||||||
|
const ACTION_BACKUP_CREATE = 'backup.create';
|
||||||
|
const ACTION_BACKUP_UPDATE = 'backup.update';
|
||||||
|
const ACTION_BACKUP_DELETE = 'backup.delete';
|
||||||
|
const ACTION_BACKUP_DOWNLOAD = 'backup.download';
|
||||||
|
|
||||||
const ACTION_ALLOCATION_READ = 'allocation.read';
|
const ACTION_ALLOCATION_READ = 'allocation.read';
|
||||||
const ACTION_ALLOCIATION_UPDATE = 'allocation.update';
|
const ACTION_ALLOCIATION_UPDATE = 'allocation.update';
|
||||||
|
|
||||||
|
@ -135,6 +141,17 @@ class Permission extends Validable
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'backup' => [
|
||||||
|
'description' => 'Permissions that control a user\'s ability to generate and manage server backups.',
|
||||||
|
'keys' => [
|
||||||
|
'create' => 'Allows a user to create new backups for this server.',
|
||||||
|
'read' => 'Allows a user to view all backups that exist for this server.',
|
||||||
|
'update' => '',
|
||||||
|
'delete' => 'Allows a user to remove backups from the system.',
|
||||||
|
'download' => 'Allows a user to download backups.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
// Controls permissions for editing or viewing a server's allocations.
|
// Controls permissions for editing or viewing a server's allocations.
|
||||||
'allocation' => [
|
'allocation' => [
|
||||||
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
|
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
|
||||||
|
|
|
@ -25,7 +25,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||||
* @property \Pterodactyl\Models\Server $server
|
* @property \Pterodactyl\Models\Server $server
|
||||||
* @property \Pterodactyl\Models\Task[]|\Illuminate\Support\Collection $tasks
|
* @property \Pterodactyl\Models\Task[]|\Illuminate\Support\Collection $tasks
|
||||||
*/
|
*/
|
||||||
class Schedule extends Validable
|
class Schedule extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
|
|
@ -52,10 +52,13 @@ use Znck\Eloquent\Traits\BelongsToThrough;
|
||||||
* @property \Pterodactyl\Models\DaemonKey $key
|
* @property \Pterodactyl\Models\DaemonKey $key
|
||||||
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
|
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
|
||||||
* @property \Pterodactyl\Models\ServerTransfer $transfer
|
* @property \Pterodactyl\Models\ServerTransfer $transfer
|
||||||
|
* @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
|
||||||
*/
|
*/
|
||||||
class Server extends Validable
|
class Server extends Model
|
||||||
{
|
{
|
||||||
use BelongsToThrough, Notifiable, Searchable;
|
use BelongsToThrough;
|
||||||
|
use Notifiable;
|
||||||
|
use Searchable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resource name for this model when it is transformed into an
|
* The resource name for this model when it is transformed into an
|
||||||
|
@ -340,7 +343,7 @@ class Server extends Validable
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all of the daemon keys belonging to this server.
|
* Returns the associated server transfer.
|
||||||
*
|
*
|
||||||
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
* @return \Illuminate\Database\Eloquent\Relations\HasOne
|
||||||
*/
|
*/
|
||||||
|
@ -348,4 +351,12 @@ class Server extends Validable
|
||||||
{
|
{
|
||||||
return $this->hasOne(ServerTransfer::class)->orderByDesc('id');
|
return $this->hasOne(ServerTransfer::class)->orderByDesc('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||||
|
*/
|
||||||
|
public function backups()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Backup::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Pterodactyl\Models;
|
namespace Pterodactyl\Models;
|
||||||
|
|
||||||
class Setting extends Validable
|
class Setting extends Model
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The table associated with the model.
|
* The table associated with the model.
|
||||||
|
|
|
@ -15,7 +15,7 @@ use Illuminate\Notifications\Notifiable;
|
||||||
* @property \Pterodactyl\Models\User $user
|
* @property \Pterodactyl\Models\User $user
|
||||||
* @property \Pterodactyl\Models\Server $server
|
* @property \Pterodactyl\Models\Server $server
|
||||||
*/
|
*/
|
||||||
class Subuser extends Validable
|
class Subuser extends Model
|
||||||
{
|
{
|
||||||
use Notifiable;
|
use Notifiable;
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||||
* @property \Pterodactyl\Models\Schedule $schedule
|
* @property \Pterodactyl\Models\Schedule $schedule
|
||||||
* @property \Pterodactyl\Models\Server $server
|
* @property \Pterodactyl\Models\Server $server
|
||||||
*/
|
*/
|
||||||
class Task extends Validable
|
class Task extends Model
|
||||||
{
|
{
|
||||||
use BelongsToThrough;
|
use BelongsToThrough;
|
||||||
|
|
||||||
|
|
|
@ -40,12 +40,17 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
|
||||||
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
|
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
|
||||||
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
|
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
|
||||||
*/
|
*/
|
||||||
class User extends Validable implements
|
class User extends Model implements
|
||||||
AuthenticatableContract,
|
AuthenticatableContract,
|
||||||
AuthorizableContract,
|
AuthorizableContract,
|
||||||
CanResetPasswordContract
|
CanResetPasswordContract
|
||||||
{
|
{
|
||||||
use Authenticatable, Authorizable, AvailableLanguages, CanResetPassword, Notifiable, Searchable;
|
use Authenticatable;
|
||||||
|
use Authorizable;
|
||||||
|
use AvailableLanguages;
|
||||||
|
use CanResetPassword;
|
||||||
|
use Notifiable;
|
||||||
|
use Searchable;
|
||||||
|
|
||||||
const USER_LEVEL_USER = 0;
|
const USER_LEVEL_USER = 0;
|
||||||
const USER_LEVEL_ADMIN = 1;
|
const USER_LEVEL_ADMIN = 1;
|
||||||
|
|
16
app/Repositories/Eloquent/BackupRepository.php
Normal file
16
app/Repositories/Eloquent/BackupRepository.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Repositories\Eloquent;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
|
||||||
|
class BackupRepository extends EloquentRepository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function model()
|
||||||
|
{
|
||||||
|
return Backup::class;
|
||||||
|
}
|
||||||
|
}
|
68
app/Services/Backups/InitiateBackupService.php
Normal file
68
app/Services/Backups/InitiateBackupService.php
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Services\Backups;
|
||||||
|
|
||||||
|
use Ramsey\Uuid\Uuid;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||||
|
|
||||||
|
class InitiateBackupService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
private $ignoredFiles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
|
||||||
|
*/
|
||||||
|
private $repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InitiateBackupService constructor.
|
||||||
|
*
|
||||||
|
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
|
||||||
|
*/
|
||||||
|
public function __construct(BackupRepository $repository)
|
||||||
|
{
|
||||||
|
$this->repository = $repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the files to be ignored by this backup.
|
||||||
|
*
|
||||||
|
* @param string|null $ignored
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setIgnoredFiles(?string $ignored)
|
||||||
|
{
|
||||||
|
$this->ignoredFiles = $ignored;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiates the backup process for a server on the daemon.
|
||||||
|
*
|
||||||
|
* @param \Pterodactyl\Models\Server $server
|
||||||
|
* @param string|null $name
|
||||||
|
* @return \Pterodactyl\Models\Backup
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function handle(Server $server, string $name = null): Backup
|
||||||
|
{
|
||||||
|
/** @var \Pterodactyl\Models\Backup $backup */
|
||||||
|
$backup = $this->repository->create([
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'uuid' => Uuid::uuid4()->toString(),
|
||||||
|
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
||||||
|
'ignored_files' => $this->ignoredFiles ?? '',
|
||||||
|
'disk' => 'local',
|
||||||
|
], true, true);
|
||||||
|
|
||||||
|
return $backup;
|
||||||
|
}
|
||||||
|
}
|
33
app/Transformers/Api/Client/BackupTransformer.php
Normal file
33
app/Transformers/Api/Client/BackupTransformer.php
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Transformers\Api\Client;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
|
||||||
|
class BackupTransformer extends BaseClientTransformer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getResourceName(): string
|
||||||
|
{
|
||||||
|
return Backup::RESOURCE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Pterodactyl\Models\Backup $backup
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function transform(Backup $backup)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uuid' => $backup->uuid,
|
||||||
|
'name' => $backup->name,
|
||||||
|
'ignored_files' => $backup->ignored_files,
|
||||||
|
'sha256_hash' => $backup->sha256_hash,
|
||||||
|
'bytes' => $backup->bytes,
|
||||||
|
'created_at' => $backup->created_at->toIso8601String(),
|
||||||
|
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
29
config/backups.php
Normal file
29
config/backups.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// The backup driver to use for this Panel instance. All client generated server backups
|
||||||
|
// will be stored in this location by default. It is possible to change this once backups
|
||||||
|
// have been made, without losing data.
|
||||||
|
'driver' => env('APP_BACKUP_DRIVER', 'local'),
|
||||||
|
|
||||||
|
'disks' => [
|
||||||
|
// There is no configuration for the local disk for Wings. That configuration
|
||||||
|
// is determined by the Daemon configuration, and not the Panel.
|
||||||
|
'local' => [],
|
||||||
|
|
||||||
|
// Configuration for storing backups in Amazon S3.
|
||||||
|
's3' => [
|
||||||
|
'region' => '',
|
||||||
|
'access_key' => '',
|
||||||
|
'access_secret_key' => '',
|
||||||
|
|
||||||
|
// The S3 bucket to use for backups.
|
||||||
|
'bucket' => '',
|
||||||
|
|
||||||
|
// The location within the S3 bucket where backups will be stored. Backups
|
||||||
|
// are stored within a folder using the server's UUID as the name. Each
|
||||||
|
// backup for that server lives within that folder.
|
||||||
|
'location' => '',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class CreateBackupsTable extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('backups', function (Blueprint $table) {
|
||||||
|
$table->bigIncrements('id');
|
||||||
|
$table->unsignedInteger('server_id');
|
||||||
|
$table->char('uuid', 36);
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('ignored_files');
|
||||||
|
$table->string('disk');
|
||||||
|
$table->string('sha256_hash')->nullable();
|
||||||
|
$table->integer('bytes')->default(0);
|
||||||
|
$table->timestamp('completed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->unique('uuid');
|
||||||
|
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('backups');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import { rawDataToServerObject, Server } from '@/api/server/getServer';
|
import { rawDataToServerObject, Server } from '@/api/server/getServer';
|
||||||
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
|
||||||
|
|
||||||
export default (): Promise<PaginatedResult<Server>> => {
|
export default (query?: string): Promise<PaginatedResult<Server>> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
http.get(`/api/client`, { params: { include: [ 'allocation' ] } })
|
http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
|
||||||
.then(({ data }) => resolve({
|
.then(({ data }) => resolve({
|
||||||
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
|
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
|
||||||
pagination: getPaginationSet(data.meta.pagination),
|
pagination: getPaginationSet(data.meta.pagination),
|
||||||
|
|
12
resources/scripts/api/server/backups/createServerBackup.ts
Normal file
12
resources/scripts/api/server/backups/createServerBackup.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||||
|
import http from '@/api/http';
|
||||||
|
|
||||||
|
export default (uuid: string, name?: string, ignore?: string): Promise<ServerBackup> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.post(`/api/client/servers/${uuid}/backups`, {
|
||||||
|
name, ignore,
|
||||||
|
})
|
||||||
|
.then(({ data }) => resolve(rawDataToServerBackup(data)))
|
||||||
|
.catch(reject);
|
||||||
|
});
|
||||||
|
};
|
32
resources/scripts/api/server/backups/getServerBackups.ts
Normal file
32
resources/scripts/api/server/backups/getServerBackups.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
|
@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
|
||||||
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
|
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
|
||||||
import { useStoreState } from 'easy-peasy';
|
import { useStoreState } from 'easy-peasy';
|
||||||
import { ApplicationStore } from '@/state';
|
import { ApplicationStore } from '@/state';
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
|
||||||
|
import SearchContainer from '@/components/dashboard/search/SearchContainer';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
const user = useStoreState((state: ApplicationStore) => state.user.data!);
|
||||||
|
@ -22,6 +24,7 @@ export default () => {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className={'right-navigation'}>
|
<div className={'right-navigation'}>
|
||||||
|
<SearchContainer/>
|
||||||
<NavLink to={'/'} exact={true}>
|
<NavLink to={'/'} exact={true}>
|
||||||
<FontAwesomeIcon icon={faLayerGroup}/>
|
<FontAwesomeIcon icon={faLayerGroup}/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
|
||||||
|
import useEventListener from '@/plugins/useEventListener';
|
||||||
|
import SearchModal from '@/components/dashboard/search/SearchModal';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
|
||||||
|
useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
|
||||||
|
if (!visible && e.key.toLowerCase() === 'k') {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible &&
|
||||||
|
<SearchModal
|
||||||
|
appear={true}
|
||||||
|
visible={visible}
|
||||||
|
onDismissed={() => setVisible(false)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div className={'navigation-link'} onClick={() => setVisible(true)}>
|
||||||
|
<FontAwesomeIcon icon={faSearch}/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
123
resources/scripts/components/dashboard/search/SearchModal.tsx
Normal file
123
resources/scripts/components/dashboard/search/SearchModal.tsx
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||||
|
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
|
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
import { debounce } from 'lodash-es';
|
||||||
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
|
import InputSpinner from '@/components/elements/InputSpinner';
|
||||||
|
import getServers from '@/api/getServers';
|
||||||
|
import { Server } from '@/api/server/getServer';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = RequiredModalProps;
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
term: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchWatcher = () => {
|
||||||
|
const { values, submitForm } = useFormikContext<Values>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values.term.length >= 3) {
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
}, [ values.term ]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ ...props }: Props) => {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
const [ loading, setLoading ] = useState(false);
|
||||||
|
const [ servers, setServers ] = useState<Server[]>([]);
|
||||||
|
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||||
|
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||||
|
|
||||||
|
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
setLoading(true);
|
||||||
|
setSubmitting(false);
|
||||||
|
clearFlashes('search');
|
||||||
|
getServers(term)
|
||||||
|
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
addError({ key: 'search', message: httpErrorToHuman(error) });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.visible) {
|
||||||
|
setTimeout(() => ref.current?.focus(), 250);
|
||||||
|
}
|
||||||
|
}, [ props.visible ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
onSubmit={search}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
term: string()
|
||||||
|
.min(3, 'Please enter at least three characters to begin searching.')
|
||||||
|
.required('A search term must be provided.'),
|
||||||
|
})}
|
||||||
|
initialValues={{ term: '' } as Values}
|
||||||
|
>
|
||||||
|
<Modal {...props}>
|
||||||
|
<Form>
|
||||||
|
<FormikFieldWrapper
|
||||||
|
name={'term'}
|
||||||
|
label={'Search term'}
|
||||||
|
description={
|
||||||
|
isAdmin
|
||||||
|
? 'Enter a server name, user email, or uuid to begin searching.'
|
||||||
|
: 'Enter a server name to begin searching.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchWatcher/>
|
||||||
|
<InputSpinner visible={loading}>
|
||||||
|
<Field
|
||||||
|
innerRef={ref}
|
||||||
|
name={'term'}
|
||||||
|
className={'input-dark'}
|
||||||
|
/>
|
||||||
|
</InputSpinner>
|
||||||
|
</FormikFieldWrapper>
|
||||||
|
</Form>
|
||||||
|
{servers.length > 0 &&
|
||||||
|
<div className={'mt-6'}>
|
||||||
|
{
|
||||||
|
servers.map(server => (
|
||||||
|
<Link
|
||||||
|
key={server.uuid}
|
||||||
|
to={`/server/${server.id}`}
|
||||||
|
className={'flex items-center block bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline hover:shadow hover:border-cyan-500 transition-colors duration-250'}
|
||||||
|
onClick={() => props.onDismissed()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className={'text-sm'}>{server.name}</p>
|
||||||
|
<p className={'mt-1 text-xs text-neutral-400'}>
|
||||||
|
{
|
||||||
|
server.allocations.filter(alloc => alloc.default).map(allocation => (
|
||||||
|
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1 text-right'}>
|
||||||
|
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
|
||||||
|
{server.node}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</Modal>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
22
resources/scripts/components/elements/InputSpinner.tsx
Normal file
22
resources/scripts/components/elements/InputSpinner.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import React from 'react';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import { CSSTransition } from 'react-transition-group';
|
||||||
|
|
||||||
|
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
|
||||||
|
<div className={'relative'}>
|
||||||
|
<CSSTransition
|
||||||
|
timeout={250}
|
||||||
|
in={visible}
|
||||||
|
unmountOnExit={true}
|
||||||
|
appear={true}
|
||||||
|
classNames={'fade'}
|
||||||
|
>
|
||||||
|
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
|
||||||
|
<Spinner size={'tiny'}/>
|
||||||
|
</div>
|
||||||
|
</CSSTransition>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default InputSpinner;
|
|
@ -3,11 +3,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
|
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
|
||||||
import { CSSTransition } from 'react-transition-group';
|
import { CSSTransition } from 'react-transition-group';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
export interface RequiredModalProps {
|
export interface RequiredModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onDismissed: () => void;
|
onDismissed: () => void;
|
||||||
appear?: boolean;
|
appear?: boolean;
|
||||||
|
top?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = RequiredModalProps & {
|
type Props = RequiredModalProps & {
|
||||||
|
@ -18,7 +20,7 @@ type Props = RequiredModalProps & {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
|
export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => {
|
||||||
const [render, setRender] = useState(visible);
|
const [render, setRender] = useState(visible);
|
||||||
|
|
||||||
const isDismissable = useMemo(() => {
|
const isDismissable = useMemo(() => {
|
||||||
|
@ -58,7 +60,7 @@ export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div className={'modal-container top'}>
|
<div className={classNames('modal-container', { top })}>
|
||||||
{isDismissable &&
|
{isDismissable &&
|
||||||
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
|
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
|
||||||
<FontAwesomeIcon icon={faTimes}/>
|
<FontAwesomeIcon icon={faTimes}/>
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||||
|
import useServer from '@/plugins/useServer';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
|
import Can from '@/components/elements/Can';
|
||||||
|
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import BackupRow from '@/components/server/backups/BackupRow';
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const { uuid } = useServer();
|
||||||
|
const { addError, clearFlashes } = useFlash();
|
||||||
|
const [ loading, setLoading ] = useState(true);
|
||||||
|
const [ backups, setBackups ] = useState<ServerBackup[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('backups');
|
||||||
|
getServerBackups(uuid)
|
||||||
|
.then(data => {
|
||||||
|
setBackups(data.items);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
addError({ key: 'backups', message: httpErrorToHuman(error) });
|
||||||
|
})
|
||||||
|
.then(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spinner size={'large'} centered={true}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'mt-10 mb-6'}>
|
||||||
|
<FlashMessageRender byKey={'backups'} className={'mb-4'}/>
|
||||||
|
{!backups.length ?
|
||||||
|
<p className="text-center text-sm text-neutral-400">
|
||||||
|
There are no backups stored for this server.
|
||||||
|
</p>
|
||||||
|
:
|
||||||
|
<div>
|
||||||
|
{backups.map((backup, index) => <BackupRow
|
||||||
|
key={backup.uuid}
|
||||||
|
backup={backup}
|
||||||
|
className={index !== (backups.length - 1) ? 'mb-2' : undefined}
|
||||||
|
/>)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<Can action={'backup.create'}>
|
||||||
|
<div className={'mt-6 flex justify-end'}>
|
||||||
|
<CreateBackupButton
|
||||||
|
onBackupGenerated={backup => setBackups(s => [...s, backup])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Can>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
47
resources/scripts/components/server/backups/BackupRow.tsx
Normal file
47
resources/scripts/components/server/backups/BackupRow.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
|
||||||
|
import format from 'date-fns/format';
|
||||||
|
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'
|
||||||
|
import Spinner from '@/components/elements/Spinner';
|
||||||
|
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
backup: ServerBackup;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({ backup, className }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={`grey-row-box flex items-center ${className}`}>
|
||||||
|
<div className={'mr-4'}>
|
||||||
|
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<p className={'text-sm mb-1'}>{backup.name}</p>
|
||||||
|
<p className={'text-xs text-neutral-400 font-mono'}>{backup.uuid}</p>
|
||||||
|
</div>
|
||||||
|
<div className={'ml-4 text-center'}>
|
||||||
|
<p
|
||||||
|
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
|
||||||
|
className={'text-sm'}
|
||||||
|
>
|
||||||
|
{distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })}
|
||||||
|
</p>
|
||||||
|
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
|
||||||
|
</div>
|
||||||
|
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
|
||||||
|
{!backup.completedAt ?
|
||||||
|
<div title={'Backup is in progress'} className={'p-2'}>
|
||||||
|
<Spinner size={'tiny'}/>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<a href={'#'} className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}>
|
||||||
|
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||||
|
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
|
import { object, string } from 'yup';
|
||||||
|
import Field from '@/components/elements/Field';
|
||||||
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
|
import useFlash from '@/plugins/useFlash';
|
||||||
|
import useServer from '@/plugins/useServer';
|
||||||
|
import createServerBackup from '@/api/server/backups/createServerBackup';
|
||||||
|
import { httpErrorToHuman } from '@/api/http';
|
||||||
|
import FlashMessageRender from '@/components/FlashMessageRender';
|
||||||
|
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||||
|
|
||||||
|
interface Values {
|
||||||
|
name: string;
|
||||||
|
ignored: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onBackupGenerated: (backup: ServerBackup) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||||
|
const { isSubmitting } = useFormikContext<Values>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
||||||
|
<Form className={'pb-6'}>
|
||||||
|
<FlashMessageRender byKey={'backups:create'} className={'mb-4'}/>
|
||||||
|
<h3 className={'mb-6'}>Create server backup</h3>
|
||||||
|
<div className={'mb-6'}>
|
||||||
|
<Field
|
||||||
|
name={'name'}
|
||||||
|
label={'Backup name'}
|
||||||
|
description={'If provided, the name that should be used to reference this backup.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'mb-6'}>
|
||||||
|
<FormikFieldWrapper
|
||||||
|
name={'ignore'}
|
||||||
|
label={'Ignored Files & Directories'}
|
||||||
|
description={`
|
||||||
|
Enter the files or folders to ignore while generating this backup. Leave blank to use
|
||||||
|
the contents of the .pteroignore file in the root of the server directory if present.
|
||||||
|
Wildcard matching of files and folders is supported in addition to negating a rule by
|
||||||
|
prefixing the path with an exclamation point.
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<FormikField
|
||||||
|
name={'contents'}
|
||||||
|
component={'textarea'}
|
||||||
|
className={'input-dark h-32'}
|
||||||
|
/>
|
||||||
|
</FormikFieldWrapper>
|
||||||
|
</div>
|
||||||
|
<div className={'flex justify-end'}>
|
||||||
|
<button
|
||||||
|
type={'submit'}
|
||||||
|
className={'btn btn-primary btn-sm'}
|
||||||
|
>
|
||||||
|
Start backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ({ onBackupGenerated }: Props) => {
|
||||||
|
const { uuid } = useServer();
|
||||||
|
const { addError, clearFlashes } = useFlash();
|
||||||
|
const [ visible, setVisible ] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearFlashes('backups:create');
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
|
clearFlashes('backups:create')
|
||||||
|
createServerBackup(uuid, name, ignored)
|
||||||
|
.then(backup => {
|
||||||
|
onBackupGenerated(backup);
|
||||||
|
setVisible(false);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
|
||||||
|
setSubmitting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible &&
|
||||||
|
<Formik
|
||||||
|
onSubmit={submit}
|
||||||
|
initialValues={{ name: '', ignored: '' }}
|
||||||
|
validationSchema={object().shape({
|
||||||
|
name: string().max(255),
|
||||||
|
ignored: string(),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
appear={true}
|
||||||
|
visible={visible}
|
||||||
|
onDismissed={() => setVisible(false)}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
className={'btn btn-primary btn-sm'}
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
>
|
||||||
|
Create backup
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -45,7 +45,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
|
||||||
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
|
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal {...props} showSpinnerOverlay={isSubmitting}>
|
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
|
||||||
<h3 ref={ref}>
|
<h3 ref={ref}>
|
||||||
{subuser ?
|
{subuser ?
|
||||||
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
|
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`
|
||||||
|
|
9
resources/scripts/easy-peasy.d.ts
vendored
Normal file
9
resources/scripts/easy-peasy.d.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
// noinspection ES6UnusedImports
|
||||||
|
import EasyPeasy from 'easy-peasy';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
declare module 'easy-peasy' {
|
||||||
|
export function useStoreState<Result>(
|
||||||
|
mapState: (state: ApplicationStore) => Result,
|
||||||
|
): Result;
|
||||||
|
}
|
23
resources/scripts/plugins/useEventListener.ts
Normal file
23
resources/scripts/plugins/useEventListener.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default (eventName: string, handler: any, element: any = window) => {
|
||||||
|
const savedHandler = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedHandler.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
const isSupported = element && element.addEventListener;
|
||||||
|
if (!isSupported) return;
|
||||||
|
|
||||||
|
const eventListener = (event: any) => savedHandler.current(event);
|
||||||
|
element.addEventListener(eventName, eventListener);
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener(eventName, eventListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[eventName, element],
|
||||||
|
);
|
||||||
|
};
|
9
resources/scripts/plugins/useFlash.ts
Normal file
9
resources/scripts/plugins/useFlash.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { Actions, useStoreActions } from 'easy-peasy';
|
||||||
|
import { FlashStore } from '@/state/flashes';
|
||||||
|
import { ApplicationStore } from '@/state';
|
||||||
|
|
||||||
|
const useFlash = (): Actions<FlashStore> => {
|
||||||
|
return useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFlash;
|
9
resources/scripts/plugins/useServer.ts
Normal file
9
resources/scripts/plugins/useServer.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { DependencyList } from 'react';
|
||||||
|
import { ServerContext } from '@/state/server';
|
||||||
|
import { Server } from '@/api/server/getServer';
|
||||||
|
|
||||||
|
const useServer = (dependencies?: DependencyList): Server => {
|
||||||
|
return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useServer;
|
|
@ -16,6 +16,7 @@ import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
|
||||||
import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer';
|
import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer';
|
||||||
import UsersContainer from '@/components/server/users/UsersContainer';
|
import UsersContainer from '@/components/server/users/UsersContainer';
|
||||||
import Can from '@/components/elements/Can';
|
import Can from '@/components/elements/Can';
|
||||||
|
import BackupContainer from '@/components/server/backups/BackupContainer';
|
||||||
|
|
||||||
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
|
||||||
const server = ServerContext.useStoreState(state => state.server.data);
|
const server = ServerContext.useStoreState(state => state.server.data);
|
||||||
|
@ -47,6 +48,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
||||||
<Can action={'user.*'}>
|
<Can action={'user.*'}>
|
||||||
<NavLink to={`${match.url}/users`}>Users</NavLink>
|
<NavLink to={`${match.url}/users`}>Users</NavLink>
|
||||||
</Can>
|
</Can>
|
||||||
|
<Can action={'backup.*'}>
|
||||||
|
<NavLink to={`${match.url}/backups`}>Backups</NavLink>
|
||||||
|
</Can>
|
||||||
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
|
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
|
||||||
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
|
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
|
||||||
</Can>
|
</Can>
|
||||||
|
@ -77,6 +81,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
|
||||||
<Route path={`${match.path}/schedules`} component={ScheduleContainer} exact/>
|
<Route path={`${match.path}/schedules`} component={ScheduleContainer} exact/>
|
||||||
<Route path={`${match.path}/schedules/:id`} component={ScheduleEditContainer} exact/>
|
<Route path={`${match.path}/schedules/:id`} component={ScheduleEditContainer} exact/>
|
||||||
<Route path={`${match.path}/users`} component={UsersContainer} exact/>
|
<Route path={`${match.path}/users`} component={UsersContainer} exact/>
|
||||||
|
<Route path={`${match.path}/backups`} component={BackupContainer} exact/>
|
||||||
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
|
<Route path={`${match.path}/settings`} component={SettingsContainer} exact/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
& > .modal-container {
|
& > .modal-container {
|
||||||
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
|
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
|
||||||
|
|
||||||
/*&.top {
|
&.top {
|
||||||
margin-top: 10%;
|
margin-top: 10%;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
& > .modal-close-icon {
|
& > .modal-close-icon {
|
||||||
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;
|
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
& .right-navigation {
|
& .right-navigation {
|
||||||
@apply .flex .h-full .items-center .justify-center;
|
@apply .flex .h-full .items-center .justify-center;
|
||||||
|
|
||||||
& > a {
|
& > a, & > .navigation-link {
|
||||||
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6;
|
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6 .cursor-pointer;
|
||||||
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
|
transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in;
|
||||||
|
|
||||||
/*! purgecss start ignore */
|
/*! purgecss start ignore */
|
||||||
|
|
|
@ -79,7 +79,7 @@
|
||||||
@if($server->threads != null)
|
@if($server->threads != null)
|
||||||
<code>{{ $server->threads }}</code>
|
<code>{{ $server->threads }}</code>
|
||||||
@else
|
@else
|
||||||
<code>Not Set</code>
|
<code>n/a</code>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -87,6 +87,14 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||||
Route::delete('/{subuser}', 'Servers\SubuserController@delete');
|
Route::delete('/{subuser}', 'Servers\SubuserController@delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::group(['prefix' => '/backups'], function () {
|
||||||
|
Route::get('/', 'Servers\BackupController@index');
|
||||||
|
Route::post('/', 'Servers\BackupController@store');
|
||||||
|
Route::get('/{backup}', 'Servers\BackupController@view');
|
||||||
|
Route::post('/{backup}', 'Servers\BackupController@update');
|
||||||
|
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
||||||
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => '/settings'], function () {
|
Route::group(['prefix' => '/settings'], function () {
|
||||||
Route::post('/rename', 'Servers\SettingsController@rename');
|
Route::post('/rename', 'Servers\SettingsController@rename');
|
||||||
Route::post('/reinstall', 'Servers\SettingsController@reinstall');
|
Route::post('/reinstall', 'Servers\SettingsController@reinstall');
|
||||||
|
|
Loading…
Reference in a new issue