Merge branch 'develop' into feature/server-transfers-actually

This commit is contained in:
Matthew Penner 2020-04-04 16:28:02 -06:00 committed by GitHub
commit fd4de9168a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1000 additions and 37 deletions

View 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()
{
}
}

View File

@ -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;
}
}

View File

@ -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',
];
}
}

View File

@ -18,7 +18,7 @@ namespace Pterodactyl\Models;
* @property \Pterodactyl\Models\Server|null $server
* @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
@ -75,7 +75,7 @@ class Allocation extends Validable
/**
* Accessor to automatically provide the IP alias if defined.
*
* @param null|string $value
* @param string|null $value
* @return string
*/
public function getAliasAttribute($value)
@ -86,7 +86,7 @@ class Allocation extends Validable
/**
* Accessor to quickly determine if this allocation has an alias.
*
* @param null|string $value
* @param string|null $value
* @return bool
*/
public function getHasAliasAttribute($value)

View File

@ -16,7 +16,7 @@ use Pterodactyl\Services\Acl\Api\AdminAcl;
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*/
class ApiKey extends Validable
class ApiKey extends Model
{
const RESOURCE_NAME = 'api_key';

82
app/Models/Backup.php Normal file
View 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);
}
}

View File

@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
use Znck\Eloquent\Traits\BelongsToThrough;
class DaemonKey extends Validable
class DaemonKey extends Model
{
use BelongsToThrough;

View File

@ -2,7 +2,7 @@
namespace Pterodactyl\Models;
class Database extends Validable
class Database extends Model
{
/**
* The resource name for this model when it is transformed into an

View File

@ -2,7 +2,7 @@
namespace Pterodactyl\Models;
class DatabaseHost extends Validable
class DatabaseHost extends Model
{
/**
* The resource name for this model when it is transformed into an

View File

@ -39,7 +39,7 @@ namespace Pterodactyl\Models;
* @property \Pterodactyl\Models\Egg|null $scriptFrom
* @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

View File

@ -2,7 +2,7 @@
namespace Pterodactyl\Models;
class EggVariable extends Validable
class EggVariable extends Model
{
/**
* The resource name for this model when it is transformed into an

View File

@ -2,7 +2,7 @@
namespace Pterodactyl\Models;
class Location extends Validable
class Location extends Model
{
/**
* The resource name for this model when it is transformed into an

View File

@ -5,11 +5,18 @@ namespace Pterodactyl\Models;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
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
* to the database.
@ -47,7 +54,7 @@ abstract class Validable extends Model
static::$validatorFactory = Container::getInstance()->make(Factory::class);
static::saving(function (Validable $model) {
static::saving(function (Model $model) {
return $model->validate();
});
}
@ -148,4 +155,19 @@ abstract class Validable extends Model
)
)->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();
}
}

View File

@ -15,7 +15,7 @@ namespace Pterodactyl\Models;
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs
* @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

View File

@ -32,9 +32,10 @@ use Pterodactyl\Models\Traits\Searchable;
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @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

View File

@ -20,7 +20,7 @@ use Pterodactyl\Models\Traits\Searchable;
* @property \Pterodactyl\Models\Egg|null $egg
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
*/
class Pack extends Validable
class Pack extends Model
{
use Searchable;

View File

@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
use Illuminate\Support\Collection;
class Permission extends Validable
class Permission extends Model
{
/**
* 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_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_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.
'allocation' => [
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',

View File

@ -25,7 +25,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property \Pterodactyl\Models\Server $server
* @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

View File

@ -52,10 +52,13 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Pterodactyl\Models\DaemonKey $key
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
* @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
@ -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
*/
@ -348,4 +351,12 @@ class Server extends Validable
{
return $this->hasOne(ServerTransfer::class)->orderByDesc('id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function backups()
{
return $this->hasMany(Backup::class);
}
}

View File

@ -2,7 +2,7 @@
namespace Pterodactyl\Models;
class Setting extends Validable
class Setting extends Model
{
/**
* The table associated with the model.

View File

@ -15,7 +15,7 @@ use Illuminate\Notifications\Notifiable;
* @property \Pterodactyl\Models\User $user
* @property \Pterodactyl\Models\Server $server
*/
class Subuser extends Validable
class Subuser extends Model
{
use Notifiable;

View File

@ -22,7 +22,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property \Pterodactyl\Models\Schedule $schedule
* @property \Pterodactyl\Models\Server $server
*/
class Task extends Validable
class Task extends Model
{
use BelongsToThrough;

View File

@ -40,12 +40,17 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
*/
class User extends Validable implements
class User extends Model implements
AuthenticatableContract,
AuthorizableContract,
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_ADMIN = 1;

View 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;
}
}

View 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;
}
}

View 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
View 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' => '',
],
],
];

View File

@ -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');
}
}

View File

@ -1,9 +1,9 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (): Promise<PaginatedResult<Server>> => {
export default (query?: string): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client`, { params: { include: [ 'allocation' ] } })
http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
.then(({ data }) => resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
pagination: getPaginationSet(data.meta.pagination),

View 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);
});
};

View 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);
});
};

View File

@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
@ -22,6 +24,7 @@ export default () => {
</Link>
</div>
<div className={'right-navigation'}>
<SearchContainer/>
<NavLink to={'/'} exact={true}>
<FontAwesomeIcon icon={faLayerGroup}/>
</NavLink>

View File

@ -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>
</>
);
};

View 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>
);
};

View 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;

View File

@ -3,11 +3,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
import classNames from 'classnames';
export interface RequiredModalProps {
visible: boolean;
onDismissed: () => void;
appear?: boolean;
top?: boolean;
}
type Props = RequiredModalProps & {
@ -18,7 +20,7 @@ type Props = RequiredModalProps & {
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 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 &&
<div className={'modal-close-icon'} onClick={() => setRender(false)}>
<FontAwesomeIcon icon={faTimes}/>

View File

@ -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>
);
};

View 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>
);
};

View File

@ -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>
</>
);
};

View File

@ -45,7 +45,7 @@ const EditSubuserModal = forwardRef<HTMLHeadingElement, Props>(({ subuser, ...pr
const permissions = useStoreState((state: ApplicationStore) => state.permissions.data);
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<Modal {...props} top={false} showSpinnerOverlay={isSubmitting}>
<h3 ref={ref}>
{subuser ?
`${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}`

9
resources/scripts/easy-peasy.d.ts vendored Normal file
View 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;
}

View 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],
);
};

View 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;

View 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;

View File

@ -16,6 +16,7 @@ import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer';
import UsersContainer from '@/components/server/users/UsersContainer';
import Can from '@/components/elements/Can';
import BackupContainer from '@/components/server/backups/BackupContainer';
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const server = ServerContext.useStoreState(state => state.server.data);
@ -47,6 +48,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Can action={'user.*'}>
<NavLink to={`${match.url}/users`}>Users</NavLink>
</Can>
<Can action={'backup.*'}>
<NavLink to={`${match.url}/backups`}>Backups</NavLink>
</Can>
<Can action={['settings.*', 'file.sftp']} matchAny={true}>
<NavLink to={`${match.url}/settings`}>Settings</NavLink>
</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/:id`} component={ScheduleEditContainer} 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/>
</Switch>
</React.Fragment>

View File

@ -6,9 +6,9 @@
& > .modal-container {
@apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex;
/*&.top {
&.top {
margin-top: 10%;
}*/
}
& > .modal-close-icon {
@apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50;

View File

@ -21,8 +21,8 @@
& .right-navigation {
@apply .flex .h-full .items-center .justify-center;
& > a {
@apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6;
& > a, & > .navigation-link {
@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;
/*! purgecss start ignore */

View File

@ -79,7 +79,7 @@
@if($server->threads != null)
<code>{{ $server->threads }}</code>
@else
<code>Not Set</code>
<code>n/a</code>
@endif
</td>
</tr>

View File

@ -87,6 +87,14 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
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::post('/rename', 'Servers\SettingsController@rename');
Route::post('/reinstall', 'Servers\SettingsController@reinstall');