diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php new file mode 100644 index 000000000..64703dd85 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -0,0 +1,77 @@ +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() + { + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php new file mode 100644 index 000000000..f938906d1 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php @@ -0,0 +1,17 @@ + 'nullable|string|max:255', + 'ignore' => 'nullable|string', + ]; + } +} diff --git a/app/Models/Allocation.php b/app/Models/Allocation.php index 00d02de80..5f2435624 100644 --- a/app/Models/Allocation.php +++ b/app/Models/Allocation.php @@ -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) diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 53a3fa810..59517621a 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -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'; diff --git a/app/Models/Backup.php b/app/Models/Backup.php new file mode 100644 index 000000000..56be90f87 --- /dev/null +++ b/app/Models/Backup.php @@ -0,0 +1,82 @@ + '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); + } +} diff --git a/app/Models/DaemonKey.php b/app/Models/DaemonKey.php index 9f5384d54..fa5bb6a91 100644 --- a/app/Models/DaemonKey.php +++ b/app/Models/DaemonKey.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Models; use Znck\Eloquent\Traits\BelongsToThrough; -class DaemonKey extends Validable +class DaemonKey extends Model { use BelongsToThrough; diff --git a/app/Models/Database.php b/app/Models/Database.php index 08ef3e566..2db45734d 100644 --- a/app/Models/Database.php +++ b/app/Models/Database.php @@ -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 diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index 88d6dcde5..6fafce2f0 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -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 diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 8db406799..1bdb48ca7 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -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 diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 727a67b9f..2db891dc9 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -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 diff --git a/app/Models/Location.php b/app/Models/Location.php index 20a8e4c38..e0871edf6 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -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 diff --git a/app/Models/Validable.php b/app/Models/Model.php similarity index 85% rename from app/Models/Validable.php rename to app/Models/Model.php index f11c8ad05..095fe7adc 100644 --- a/app/Models/Validable.php +++ b/app/Models/Model.php @@ -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(); + } } diff --git a/app/Models/Nest.php b/app/Models/Nest.php index af77dc461..d2839919d 100644 --- a/app/Models/Nest.php +++ b/app/Models/Nest.php @@ -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 diff --git a/app/Models/Node.php b/app/Models/Node.php index a38458dd6..9518084c5 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -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 diff --git a/app/Models/Pack.php b/app/Models/Pack.php index 092f1cbf2..3846d74eb 100644 --- a/app/Models/Pack.php +++ b/app/Models/Pack.php @@ -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; diff --git a/app/Models/Permission.php b/app/Models/Permission.php index df19f595c..15d70c4ee 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -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.', diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 21cad0e59..384d354ad 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -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 diff --git a/app/Models/Server.php b/app/Models/Server.php index 936e69464..97fea38a8 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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); + } } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index c23afb6e8..1a91a578e 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class Setting extends Validable +class Setting extends Model { /** * The table associated with the model. diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index e5e8e318e..d75bbe9ab 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -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; diff --git a/app/Models/Task.php b/app/Models/Task.php index 83d4119ff..f5a26b78a 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -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; diff --git a/app/Models/User.php b/app/Models/User.php index e3fccad43..c8efc7bd6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php new file mode 100644 index 000000000..2ff206325 --- /dev/null +++ b/app/Repositories/Eloquent/BackupRepository.php @@ -0,0 +1,16 @@ +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; + } +} diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php new file mode 100644 index 000000000..53966fc77 --- /dev/null +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -0,0 +1,33 @@ + $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, + ]; + } +} diff --git a/config/backups.php b/config/backups.php new file mode 100644 index 000000000..57edfee30 --- /dev/null +++ b/config/backups.php @@ -0,0 +1,29 @@ + 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' => '', + ], + ], +]; diff --git a/database/migrations/2020_04_03_230614_create_backups_table.php b/database/migrations/2020_04_03_230614_create_backups_table.php new file mode 100644 index 000000000..63dad39a0 --- /dev/null +++ b/database/migrations/2020_04_03_230614_create_backups_table.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index b77440da2..c67322a78 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -1,9 +1,9 @@ import { rawDataToServerObject, Server } from '@/api/server/getServer'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; -export default (): Promise> => { +export default (query?: string): Promise> => { 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), diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts new file mode 100644 index 000000000..2616c2bf3 --- /dev/null +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -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 => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/backups`, { + name, ignore, + }) + .then(({ data }) => resolve(rawDataToServerBackup(data))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/backups/getServerBackups.ts b/resources/scripts/api/server/backups/getServerBackups.ts new file mode 100644 index 000000000..49f3aa24c --- /dev/null +++ b/resources/scripts/api/server/backups/getServerBackups.ts @@ -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> => { + 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); + }); +}; diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 0482069e5..7ff60bef0 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -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 () => {
+ diff --git a/resources/scripts/components/dashboard/search/SearchContainer.tsx b/resources/scripts/components/dashboard/search/SearchContainer.tsx new file mode 100644 index 000000000..475d65510 --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchContainer.tsx @@ -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 && + setVisible(false)} + /> + } +
setVisible(true)}> + +
+ + ); +}; diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx new file mode 100644 index 000000000..af6f6fcca --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -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(); + + useEffect(() => { + if (values.term.length >= 3) { + submitForm(); + } + }, [ values.term ]); + + return null; +}; + +export default ({ ...props }: Props) => { + const ref = useRef(null); + const [ loading, setLoading ] = useState(false); + const [ servers, setServers ] = useState([]); + const isAdmin = useStoreState(state => state.user.data!.rootAdmin); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers) => { + 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 ( + + +
+ + + + + + +
+ {servers.length > 0 && +
+ { + servers.map(server => ( + props.onDismissed()} + > +
+

{server.name}

+

+ { + server.allocations.filter(alloc => alloc.default).map(allocation => ( + {allocation.alias || allocation.ip}:{allocation.port} + )) + } +

+
+
+ + {server.node} + +
+ + )) + } +
+ } +
+
+ ); +}; diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx new file mode 100644 index 000000000..1dbc3c84a --- /dev/null +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -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 }) => ( +
+ +
+ +
+
+ {children} +
+); + +export default InputSpinner; diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index 88cc78f0c..c37669b02 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -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 } } }}> -
+
{isDismissable &&
setRender(false)}> diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx new file mode 100644 index 000000000..dee9342c9 --- /dev/null +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -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([]); + + 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 ; + } + + return ( +
+ + {!backups.length ? +

+ There are no backups stored for this server. +

+ : +
+ {backups.map((backup, index) => )} +
+ } + +
+ setBackups(s => [...s, backup])} + /> +
+
+
+ ); +}; diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx new file mode 100644 index 000000000..ec98cc885 --- /dev/null +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -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 ( +
+
+ +
+
+

{backup.name}

+

{backup.uuid}

+
+
+

+ {distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} +

+

Created

+
+
+ {!backup.completedAt ? +
+ +
+ : + + + + } +
+
+ ); +}; diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx new file mode 100644 index 000000000..256a88325 --- /dev/null +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -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(); + + return ( + +
+ +

Create server backup

+
+ +
+
+ + + +
+
+ +
+ +
+ ); +}; + +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) => { + 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 && + + setVisible(false)} + /> + + } + + + ); +}; diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index 5c49edb28..bd6c0dfdb 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -45,7 +45,7 @@ const EditSubuserModal = forwardRef(({ subuser, ...pr const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); return ( - +

{subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` diff --git a/resources/scripts/easy-peasy.d.ts b/resources/scripts/easy-peasy.d.ts new file mode 100644 index 000000000..939ad54cf --- /dev/null +++ b/resources/scripts/easy-peasy.d.ts @@ -0,0 +1,9 @@ +// noinspection ES6UnusedImports +import EasyPeasy from 'easy-peasy'; +import { ApplicationStore } from '@/state'; + +declare module 'easy-peasy' { + export function useStoreState( + mapState: (state: ApplicationStore) => Result, + ): Result; +} diff --git a/resources/scripts/plugins/useEventListener.ts b/resources/scripts/plugins/useEventListener.ts new file mode 100644 index 000000000..7cb14690a --- /dev/null +++ b/resources/scripts/plugins/useEventListener.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +export default (eventName: string, handler: any, element: any = window) => { + const savedHandler = useRef(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], + ); +}; diff --git a/resources/scripts/plugins/useFlash.ts b/resources/scripts/plugins/useFlash.ts new file mode 100644 index 000000000..a55b87312 --- /dev/null +++ b/resources/scripts/plugins/useFlash.ts @@ -0,0 +1,9 @@ +import { Actions, useStoreActions } from 'easy-peasy'; +import { FlashStore } from '@/state/flashes'; +import { ApplicationStore } from '@/state'; + +const useFlash = (): Actions => { + return useStoreActions((actions: Actions) => actions.flashes); +}; + +export default useFlash; diff --git a/resources/scripts/plugins/useServer.ts b/resources/scripts/plugins/useServer.ts new file mode 100644 index 000000000..40fd93da1 --- /dev/null +++ b/resources/scripts/plugins/useServer.ts @@ -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; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 241337b79..ce592dfeb 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -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 }>) Users + + Backups + Settings @@ -77,6 +81,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + diff --git a/resources/styles/components/modal.css b/resources/styles/components/modal.css index 5fdceaa87..550191c6b 100644 --- a/resources/styles/components/modal.css +++ b/resources/styles/components/modal.css @@ -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; diff --git a/resources/styles/components/navigation.css b/resources/styles/components/navigation.css index 16f64e41d..31951ebfb 100644 --- a/resources/styles/components/navigation.css +++ b/resources/styles/components/navigation.css @@ -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 */ diff --git a/resources/views/admin/servers/view/index.blade.php b/resources/views/admin/servers/view/index.blade.php index 10b67c278..eccecba70 100644 --- a/resources/views/admin/servers/view/index.blade.php +++ b/resources/views/admin/servers/view/index.blade.php @@ -79,7 +79,7 @@ @if($server->threads != null) {{ $server->threads }} @else - Not Set + n/a @endif diff --git a/routes/api-client.php b/routes/api-client.php index 0a6fa39e9..d0619949f 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -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');