Very basic implemention of frontend logic required to display backups and create a new one
This commit is contained in:
parent
17ec4efd3b
commit
9991989f89
16 changed files with 431 additions and 2 deletions
54
app/Http/Controllers/Api/Client/Servers/BackupController.php
Normal file
54
app/Http/Controllers/Api/Client/Servers/BackupController.php
Normal 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()
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -6,9 +6,10 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $server_id
|
||||
* @property int $uuid
|
||||
* @property string $name
|
||||
* @property string $contents
|
||||
* @property string $ignore
|
||||
* @property string $disk
|
||||
* @property string|null $sha256_hash
|
||||
* @property int $bytes
|
||||
|
@ -16,11 +17,15 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
* @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
|
||||
*/
|
||||
|
@ -56,4 +61,12 @@ class Backup extends Model
|
|||
{
|
||||
return $this->asImmutableDateTime($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
*/
|
||||
public function server()
|
||||
{
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,12 @@ class Permission extends Model
|
|||
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 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.
|
||||
'allocation' => [
|
||||
'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
|
||||
|
|
|
@ -51,6 +51,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
|
|||
* @property \Pterodactyl\Models\Location $location
|
||||
* @property \Pterodactyl\Models\DaemonKey $key
|
||||
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
|
||||
* @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
|
||||
*/
|
||||
class Server extends Model
|
||||
{
|
||||
|
@ -339,4 +340,12 @@ class Server extends Model
|
|||
{
|
||||
return $this->hasMany(DaemonKey::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
*/
|
||||
public function backups()
|
||||
{
|
||||
return $this->hasMany(Backup::class);
|
||||
}
|
||||
}
|
||||
|
|
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,
|
||||
'ignore' => $backup->ignore,
|
||||
'sha256_hash' => $backup->sha256_hash,
|
||||
'bytes' => $backup->bytes,
|
||||
'created_at' => $backup->created_at->toIso8601String(),
|
||||
'completed_at' => $backup->completed_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
|
@ -15,15 +15,19 @@ class CreateBackupsTable extends Migration
|
|||
{
|
||||
Schema::create('backups', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->unsignedInteger('server_id');
|
||||
$table->char('uuid', 36);
|
||||
$table->string('name');
|
||||
$table->text('contents');
|
||||
$table->text('ignored');
|
||||
$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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
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.attributes)))
|
||||
.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;
|
||||
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);
|
||||
});
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
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 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>
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Reference in a new issue