Very basic implemention of frontend logic required to display backups and create a new one

This commit is contained in:
Dane Everitt 2020-04-04 10:59:25 -07:00
parent 17ec4efd3b
commit 9991989f89
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
16 changed files with 431 additions and 2 deletions

View file

@ -0,0 +1,54 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
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
{
public function __construct()
{
parent::__construct();
}
/**
* 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
*/
public function store(StoreBackupRequest $request, Server $server)
{
}
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

@ -6,9 +6,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
/** /**
* @property int $id * @property int $id
* @property int $server_id
* @property int $uuid * @property int $uuid
* @property string $name * @property string $name
* @property string $contents * @property string $ignore
* @property string $disk * @property string $disk
* @property string|null $sha256_hash * @property string|null $sha256_hash
* @property int $bytes * @property int $bytes
@ -16,11 +17,15 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at * @property \Carbon\CarbonImmutable $updated_at
* @property \Carbon\CarbonImmutable|null $deleted_at * @property \Carbon\CarbonImmutable|null $deleted_at
*
* @property \Pterodactyl\Models\Server $server
*/ */
class Backup extends Model class Backup extends Model
{ {
use SoftDeletes; use SoftDeletes;
const RESOURCE_NAME = 'backup';
/** /**
* @var string * @var string
*/ */
@ -56,4 +61,12 @@ class Backup extends Model
{ {
return $this->asImmutableDateTime($value); return $this->asImmutableDateTime($value);
} }
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function server()
{
return $this->belongsTo(Server::class);
}
} }

View file

@ -37,6 +37,12 @@ class Permission extends Model
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 Model
], ],
], ],
'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.',

View file

@ -51,6 +51,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\Location $location
* @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\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
*/ */
class Server extends Model class Server extends Model
{ {
@ -339,4 +340,12 @@ class Server extends Model
{ {
return $this->hasMany(DaemonKey::class); return $this->hasMany(DaemonKey::class);
} }
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function backups()
{
return $this->hasMany(Backup::class);
}
} }

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,
'ignore' => $backup->ignore,
'sha256_hash' => $backup->sha256_hash,
'bytes' => $backup->bytes,
'created_at' => $backup->created_at->toIso8601String(),
'completed_at' => $backup->completed_at->toIso8601String(),
];
}
}

View file

@ -15,15 +15,19 @@ class CreateBackupsTable extends Migration
{ {
Schema::create('backups', function (Blueprint $table) { Schema::create('backups', function (Blueprint $table) {
$table->bigIncrements('id'); $table->bigIncrements('id');
$table->unsignedInteger('server_id');
$table->char('uuid', 36); $table->char('uuid', 36);
$table->string('name'); $table->string('name');
$table->text('contents'); $table->text('ignored');
$table->string('disk'); $table->string('disk');
$table->string('sha256_hash')->nullable(); $table->string('sha256_hash')->nullable();
$table->integer('bytes')->default(0); $table->integer('bytes')->default(0);
$table->timestamp('completed_at')->nullable(); $table->timestamp('completed_at')->nullable();
$table->timestamps(); $table->timestamps();
$table->softDeletes(); $table->softDeletes();
$table->unique('uuid');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
}); });
} }

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.attributes)))
.catch(reject);
});
};

View file

@ -0,0 +1,32 @@
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
export interface ServerBackup {
uuid: string;
name: string;
contents: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
name: attributes.name,
contents: attributes.contents,
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

@ -0,0 +1,61 @@
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';
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 => (
<div key={backup.uuid}>
{backup.uuid}
</div>
))
}
</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,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

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

View file

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