diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index f0f6471f..f042660d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2 ] + php: [ 8.4 ] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index baeb0bf3..d5657279 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2 ] + php: [ 8.4 ] node-version: [ "20.x" ] steps: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2dee1c52..0f349949 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: true matrix: - php: [ 8.2 ] + php: [ 8.4 ] steps: - uses: actions/checkout@v4 diff --git a/app/Actions/Database/RestoreBackup.php b/app/Actions/Database/RestoreBackup.php index 535b59d8..0d57ad1f 100644 --- a/app/Actions/Database/RestoreBackup.php +++ b/app/Actions/Database/RestoreBackup.php @@ -5,7 +5,10 @@ use App\Enums\BackupFileStatus; use App\Models\BackupFile; use App\Models\Database; +use App\Models\Server; use App\Models\Service; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; class RestoreBackup { @@ -14,6 +17,8 @@ class RestoreBackup */ public function restore(BackupFile $backupFile, array $input): void { + Validator::make($input, self::rules($backupFile->backup->server))->validate(); + /** @var Database $database */ $database = Database::query()->findOrFail($input['database']); $backupFile->status = BackupFileStatus::RESTORING; @@ -38,12 +43,12 @@ public function restore(BackupFile $backupFile, array $input): void /** * @return array> */ - public static function rules(): array + public static function rules(Server $server): array { return [ 'database' => [ 'required', - 'exists:databases,id', + Rule::exists('databases', 'id')->where('server_id', $server->id), ], ]; } diff --git a/app/Http/Controllers/DatabaseBackupController.php b/app/Http/Controllers/BackupController.php similarity index 64% rename from app/Http/Controllers/DatabaseBackupController.php rename to app/Http/Controllers/BackupController.php index 324a53bb..aaafd4cd 100644 --- a/app/Http/Controllers/DatabaseBackupController.php +++ b/app/Http/Controllers/BackupController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers; use App\Actions\Database\ManageBackup; +use App\Actions\Database\RunBackup; use App\Http\Resources\BackupFileResource; use App\Http\Resources\BackupResource; use App\Models\Backup; +use App\Models\BackupFile; use App\Models\Server; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -15,26 +17,27 @@ use Spatie\RouteAttributes\Attributes\Delete; use Spatie\RouteAttributes\Attributes\Get; use Spatie\RouteAttributes\Attributes\Middleware; +use Spatie\RouteAttributes\Attributes\Patch; use Spatie\RouteAttributes\Attributes\Post; use Spatie\RouteAttributes\Attributes\Prefix; #[Prefix('servers/{server}/database/backups')] #[Middleware(['auth', 'has-project'])] -class DatabaseBackupController extends Controller +class BackupController extends Controller { - #[Get('/', name: 'database-backups')] + #[Get('/', name: 'backups')] public function index(Server $server): Response { $this->authorize('viewAny', [Backup::class, $server]); - return Inertia::render('database-backups/index', [ + return Inertia::render('backups/index', [ 'backups' => BackupResource::collection( $server->backups()->with('lastFile')->simplePaginate(config('web.pagination_size')) ), ]); } - #[Get('/{backup}', name: 'database-backups.show')] + #[Get('/{backup}', name: 'backups.show')] public function show(Server $server, Backup $backup): JsonResponse { $this->authorize('view', $backup); @@ -45,7 +48,7 @@ public function show(Server $server, Backup $backup): JsonResponse ]); } - #[Post('/', name: 'database-backups.store')] + #[Post('/', name: 'backups.store')] public function store(Request $request, Server $server): RedirectResponse { $this->authorize('create', [Backup::class, $server]); @@ -56,7 +59,29 @@ public function store(Request $request, Server $server): RedirectResponse ->with('info', 'Backup is being created...'); } - #[Delete('/{backup}', name: 'database-backups.destroy')] + #[Patch('/{backup}', name: 'backups.update')] + public function update(Request $request, Server $server, Backup $backup): RedirectResponse + { + $this->authorize('update', $backup); + + app(ManageBackup::class)->update($backup, $request->all()); + + return back() + ->with('success', 'Backup updated successfully.'); + } + + #[Post('/{backup}/run', name: 'backups.run')] + public function run(Server $server, Backup $backup): RedirectResponse + { + $this->authorize('create', [BackupFile::class, $backup]); + + app(RunBackup::class)->run($backup); + + return back() + ->with('info', 'Backup is being created...'); + } + + #[Delete('/{backup}', name: 'backups.destroy')] public function destroy(Server $server, Backup $backup): RedirectResponse { $this->authorize('delete', $backup); diff --git a/app/Http/Controllers/BackupFileController.php b/app/Http/Controllers/BackupFileController.php new file mode 100644 index 00000000..3ebd1405 --- /dev/null +++ b/app/Http/Controllers/BackupFileController.php @@ -0,0 +1,52 @@ +authorize('viewAny', [BackupFile::class, $backup]); + + return BackupFileResource::collection($backup->files()->latest()->simplePaginate(config('web.pagination_size'))); + } + + #[Post('/{backupFile}/restore', name: 'backup-files.restore')] + public function restore(Request $request, Server $server, Backup $backup, BackupFile $backupFile): RedirectResponse + { + $this->authorize('update', $backup); + + app(RestoreBackup::class)->restore($backupFile, $request->input()); + + return back() + ->with('info', 'Backup is being restored...'); + } + + #[Delete('/{backupFile}', name: 'backup-files.destroy')] + public function destroy(Server $server, Backup $backup, BackupFile $backupFile): RedirectResponse + { + $this->authorize('delete', $backupFile); + + $backupFile->deleteFile(); + + return back() + ->with('success', 'File deleted successfully.'); + } +} diff --git a/app/Http/Resources/BackupFileResource.php b/app/Http/Resources/BackupFileResource.php index a0086bff..c2f1b060 100644 --- a/app/Http/Resources/BackupFileResource.php +++ b/app/Http/Resources/BackupFileResource.php @@ -2,7 +2,6 @@ namespace App\Http\Resources; -use App\Models\Backup; use App\Models\BackupFile; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -18,12 +17,13 @@ public function toArray(Request $request): array return [ 'id' => $this->id, 'backup_id' => $this->backup_id, + 'server_id' => $this->backup->server_id, 'name' => $this->name, 'size' => $this->size, 'restored_to' => $this->restored_to, 'restored_at' => $this->restored_at, 'status' => $this->status, - 'status_color' => Backup::$statusColors[$this->status] ?? 'outline', + 'status_color' => BackupFile::$statusColors[$this->status] ?? 'outline', 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Models/BackupFile.php b/app/Models/BackupFile.php index 600a654d..90fa00f9 100644 --- a/app/Models/BackupFile.php +++ b/app/Models/BackupFile.php @@ -6,6 +6,7 @@ use App\Enums\BackupFileStatus; use App\Enums\StorageProvider as StorageProviderAlias; use Carbon\Carbon; +use Database\Factories\BackupFileFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -20,7 +21,7 @@ */ class BackupFile extends AbstractModel { - /** @use HasFactory<\Database\Factories\BackupFileFactory> */ + /** @use HasFactory */ use HasFactory; protected $fillable = [ diff --git a/app/Models/ServerLog.php b/app/Models/ServerLog.php index 3291b13d..dabd6a4e 100755 --- a/app/Models/ServerLog.php +++ b/app/Models/ServerLog.php @@ -149,7 +149,7 @@ public function getContent(?int $lines = null): ?string return $content ?? 'Empty log file!'; } - return "Log file doesn't exist!"; + return "Log file doesn't exist or is empty!"; } public static function log(Server $server, string $type, string $content, ?Site $site = null): ServerLog diff --git a/composer.json b/composer.json index d57b7c15..8a8333fd 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ ], "license": "AGPL-3.0", "require": { - "php": "^8.2", + "php": "^8.4", "ext-ftp": "*", "ext-intl": "*", "aws/aws-sdk-php": "^3.158", diff --git a/composer.lock b/composer.lock index 29c8e595..eb40a1f2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "436252e5b1fca532bfad2c3eb094374c", + "content-hash": "982a4f11f6f8aef1d93da9b59337c732", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.343.3", + "version": "3.343.17", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979" + "reference": "e14b2f6ede131ef79796ff814d87126578f90e9b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979", - "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e14b2f6ede131ef79796ff814d87126578f90e9b", + "reference": "e14b2f6ede131ef79796ff814d87126578f90e9b", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.343.3" + "source": "https://github.com/aws/aws-sdk-php/tree/3.343.17" }, - "time": "2025-05-02T18:04:58+00:00" + "time": "2025-05-22T18:03:05+00:00" }, { "name": "bacon/bacon-qr-code", @@ -1585,16 +1585,16 @@ }, { "name": "laravel/framework", - "version": "v12.12.0", + "version": "v12.15.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7" + "reference": "2ef7fb183f18e547af4eb9f5a55b2ac1011f0b77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/8f6cd73696068c28f30f5964556ec9d14e5d90d7", - "reference": "8f6cd73696068c28f30f5964556ec9d14e5d90d7", + "url": "https://api.github.com/repos/laravel/framework/zipball/2ef7fb183f18e547af4eb9f5a55b2ac1011f0b77", + "reference": "2ef7fb183f18e547af4eb9f5a55b2ac1011f0b77", "shasum": "" }, "require": { @@ -1615,7 +1615,7 @@ "guzzlehttp/uri-template": "^1.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^1.3|^2.0", - "league/commonmark": "^2.6", + "league/commonmark": "^2.7", "league/flysystem": "^3.25.1", "league/flysystem-local": "^3.25.1", "league/uri": "^7.5.1", @@ -1707,7 +1707,7 @@ "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", - "predis/predis": "^2.3", + "predis/predis": "^2.3|^3.0", "resend/resend-php": "^0.10.0", "symfony/cache": "^7.2.0", "symfony/http-client": "^7.2.0", @@ -1739,7 +1739,7 @@ "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", - "predis/predis": "Required to use the predis connector (^2.3).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).", @@ -1796,7 +1796,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-01T16:13:12+00:00" + "time": "2025-05-20T15:10:44+00:00" }, { "name": "laravel/prompts", @@ -2050,16 +2050,16 @@ }, { "name": "league/commonmark", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94" + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/06c3b0bf2540338094575612f4a1778d0d2d5e94", - "reference": "06c3b0bf2540338094575612f4a1778d0d2d5e94", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", + "reference": "6fbb36d44824ed4091adbcf4c7d4a3923cdb3405", "shasum": "" }, "require": { @@ -2096,7 +2096,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.7-dev" + "dev-main": "2.8-dev" } }, "autoload": { @@ -2153,7 +2153,7 @@ "type": "tidelift" } ], - "time": "2025-04-18T21:09:27+00:00" + "time": "2025-05-05T12:20:28+00:00" }, { "name": "league/config", @@ -3147,31 +3147,31 @@ }, { "name": "nunomaduro/termwind", - "version": "v2.3.0", + "version": "v2.3.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda" + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/52915afe6a1044e8b9cee1bcff836fb63acf9cda", - "reference": "52915afe6a1044e8b9cee1bcff836fb63acf9cda", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/dfa08f390e509967a15c22493dc0bac5733d9123", + "reference": "dfa08f390e509967a15c22493dc0bac5733d9123", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.1.8" + "symfony/console": "^7.2.6" }, "require-dev": { - "illuminate/console": "^11.33.2", - "laravel/pint": "^1.18.2", + "illuminate/console": "^11.44.7", + "laravel/pint": "^1.22.0", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0", - "phpstan/phpstan": "^1.12.11", - "phpstan/phpstan-strict-rules": "^1.6.1", - "symfony/var-dumper": "^7.1.8", + "pestphp/pest": "^2.36.0 || ^3.8.2", + "phpstan/phpstan": "^1.12.25", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.2.6", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -3214,7 +3214,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.0" + "source": "https://github.com/nunomaduro/termwind/tree/v2.3.1" }, "funding": [ { @@ -3230,7 +3230,7 @@ "type": "github" } ], - "time": "2024-11-21T10:39:51+00:00" + "time": "2025-05-08T08:14:37+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -4340,16 +4340,16 @@ }, { "name": "spatie/backtrace", - "version": "1.7.2", + "version": "1.7.4", "source": { "type": "git", "url": "https://github.com/spatie/backtrace.git", - "reference": "9807de6b8fecfaa5b3d10650985f0348b02862b2" + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/backtrace/zipball/9807de6b8fecfaa5b3d10650985f0348b02862b2", - "reference": "9807de6b8fecfaa5b3d10650985f0348b02862b2", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/cd37a49fce7137359ac30ecc44ef3e16404cccbe", + "reference": "cd37a49fce7137359ac30ecc44ef3e16404cccbe", "shasum": "" }, "require": { @@ -4387,7 +4387,7 @@ "spatie" ], "support": { - "source": "https://github.com/spatie/backtrace/tree/1.7.2" + "source": "https://github.com/spatie/backtrace/tree/1.7.4" }, "funding": [ { @@ -4399,7 +4399,7 @@ "type": "other" } ], - "time": "2025-04-28T14:55:53+00:00" + "time": "2025-05-08T15:41:09+00:00" }, { "name": "spatie/laravel-route-attributes", @@ -6775,16 +6775,16 @@ }, { "name": "tightenco/ziggy", - "version": "v2.5.2", + "version": "v2.5.3", "source": { "type": "git", "url": "https://github.com/tighten/ziggy.git", - "reference": "d59dbb61dc0a1d9abb2130451b9e5e0f264bfe1c" + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tighten/ziggy/zipball/d59dbb61dc0a1d9abb2130451b9e5e0f264bfe1c", - "reference": "d59dbb61dc0a1d9abb2130451b9e5e0f264bfe1c", + "url": "https://api.github.com/repos/tighten/ziggy/zipball/0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", + "reference": "0b3b521d2c55fbdb04b6721532f7f5f49d32f52b", "shasum": "" }, "require": { @@ -6839,9 +6839,9 @@ ], "support": { "issues": "https://github.com/tighten/ziggy/issues", - "source": "https://github.com/tighten/ziggy/tree/v2.5.2" + "source": "https://github.com/tighten/ziggy/tree/v2.5.3" }, - "time": "2025-02-27T15:43:52+00:00" + "time": "2025-05-17T18:15:19+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -7577,16 +7577,16 @@ }, { "name": "laravel/pint", - "version": "v1.22.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", "shasum": "" }, "require": { @@ -7598,11 +7598,11 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.3.1", + "illuminate/view": "^11.44.7", + "larastan/larastan": "^3.4.0", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -7639,20 +7639,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-04-08T22:11:45+00:00" + "time": "2025-05-08T08:38:12+00:00" }, { "name": "laravel/sail", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6" + "reference": "71a509b14b2621ce58574274a74290f933c687f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", - "reference": "2edaaf77f3c07a4099965bb3d7dfee16e801c0f6", + "url": "https://api.github.com/repos/laravel/sail/zipball/71a509b14b2621ce58574274a74290f933c687f7", + "reference": "71a509b14b2621ce58574274a74290f933c687f7", "shasum": "" }, "require": { @@ -7702,7 +7702,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-04-29T14:26:46+00:00" + "time": "2025-05-13T13:34:34+00:00" }, { "name": "mockery/mockery", @@ -8119,16 +8119,16 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.14", + "version": "2.1.17", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2" + "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2", - "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/89b5ef665716fa2a52ecd2633f21007a6a349053", + "reference": "89b5ef665716fa2a52ecd2633f21007a6a349053", "shasum": "" }, "require": { @@ -8173,7 +8173,7 @@ "type": "github" } ], - "time": "2025-05-02T15:32:28+00:00" + "time": "2025-05-21T20:55:28+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8500,16 +8500,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.19", + "version": "11.5.21", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5" + "reference": "d565e2cdc21a7db9dc6c399c1fc2083b8010f289" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5", - "reference": "0da1ebcdbc4d5bd2d189cfe02846a89936d8dda5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d565e2cdc21a7db9dc6c399c1fc2083b8010f289", + "reference": "d565e2cdc21a7db9dc6c399c1fc2083b8010f289", "shasum": "" }, "require": { @@ -8532,7 +8532,7 @@ "sebastian/code-unit": "^3.0.3", "sebastian/comparator": "^6.3.1", "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/exporter": "^6.3.0", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", @@ -8581,7 +8581,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.19" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.21" }, "funding": [ { @@ -8605,25 +8605,25 @@ "type": "tidelift" } ], - "time": "2025-05-02T06:56:52+00:00" + "time": "2025-05-21T12:35:00+00:00" }, { "name": "rector/rector", - "version": "2.0.14", + "version": "2.0.16", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "63923bc9383c1212476c41d8cebf58a425e6f98d" + "reference": "f1366d1f8c7490541c8f7af6e5c7cef7cca1b5a2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/63923bc9383c1212476c41d8cebf58a425e6f98d", - "reference": "63923bc9383c1212476c41d8cebf58a425e6f98d", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f1366d1f8c7490541c8f7af6e5c7cef7cca1b5a2", + "reference": "f1366d1f8c7490541c8f7af6e5c7cef7cca1b5a2", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.12" + "phpstan/phpstan": "^2.1.14" }, "conflict": { "rector/rector-doctrine": "*", @@ -8656,7 +8656,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.0.14" + "source": "https://github.com/rectorphp/rector/tree/2.0.16" }, "funding": [ { @@ -8664,7 +8664,7 @@ "type": "github" } ], - "time": "2025-04-28T00:03:14+00:00" + "time": "2025-05-12T16:37:16+00:00" }, { "name": "sebastian/cli-parser", @@ -9043,23 +9043,23 @@ }, { "name": "sebastian/environment", - "version": "7.2.0", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "suggest": { "ext-posix": "*" @@ -9095,15 +9095,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2025-05-21T11:55:47+00:00" }, { "name": "sebastian/exporter", @@ -10263,7 +10275,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2", + "php": "^8.4", "ext-ftp": "*", "ext-intl": "*" }, diff --git a/package-lock.json b/package-lock.json index 64eec195..9a105a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-query": "^5.76.1", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", @@ -2876,6 +2877,32 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tanstack/query-core": { + "version": "5.76.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.76.0.tgz", + "integrity": "sha512-FN375hb8ctzfNAlex5gHI6+WDXTNpe0nbxp/d2YJtnP+IBM6OUm7zcaoCW6T63BawGOYZBbKC0iPvr41TteNVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.76.1.tgz", + "integrity": "sha512-YxdLZVGN4QkT5YT1HKZQWiIlcgauIXEIsMOTSjvyD5wLYK8YVvKZUPAysMqossFJJfDpJW3pFn7WNZuPOqq+fw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.76.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", diff --git a/package.json b/package.json index d3ef5730..377a2df6 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.6", "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-query": "^5.76.1", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 2a628021..37f929e7 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -14,11 +14,10 @@ import { } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, CogIcon, Folder, MinusIcon, PlusIcon, ServerIcon } from 'lucide-react'; +import { BookOpen, ChevronRightIcon, CogIcon, Folder, ServerIcon } from 'lucide-react'; import AppLogo from './app-logo'; import { Icon } from '@/components/icon'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useState } from 'react'; const mainNavItems: NavItem[] = [ { @@ -47,8 +46,6 @@ const footerNavItems: NavItem[] = [ ]; export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems?: NavItem[]; secondNavTitle?: string }) { - const [open, setOpen] = useState(); - return ( {/* This is the first sidebar */} @@ -124,12 +121,7 @@ export function AppSidebar({ secondNavItems, secondNavTitle }: { secondNavItems? if (item.children && item.children.length > 0) { return ( - (value ? setOpen(item) : setOpen(undefined))} - className="group/collapsible" - > + diff --git a/resources/js/components/project-switch.tsx b/resources/js/components/project-switch.tsx index 90669a4e..87164f83 100644 --- a/resources/js/components/project-switch.tsx +++ b/resources/js/components/project-switch.tsx @@ -1,5 +1,5 @@ import { type SharedData } from '@/types'; -import { Link, useForm, usePage } from '@inertiajs/react'; +import { useForm, usePage } from '@inertiajs/react'; import { useState } from 'react'; import { DropdownMenu, @@ -32,17 +32,13 @@ export function ProjectSwitch() { return (
- - - diff --git a/resources/js/components/server-switch.tsx b/resources/js/components/server-switch.tsx index c6abea4a..3f105efc 100644 --- a/resources/js/components/server-switch.tsx +++ b/resources/js/components/server-switch.tsx @@ -1,4 +1,4 @@ -import { Link, useForm, usePage } from '@inertiajs/react'; +import { useForm, usePage } from '@inertiajs/react'; import { useState } from 'react'; import { DropdownMenu, @@ -29,29 +29,27 @@ export function ServerSwitch() { return (
- {selectedServer && ( - - - - )} - - {!selectedServer && ( - - )} - diff --git a/resources/js/components/table-skeleton.tsx b/resources/js/components/table-skeleton.tsx new file mode 100644 index 00000000..10448f56 --- /dev/null +++ b/resources/js/components/table-skeleton.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { cn } from '@/lib/utils'; + +export function TableSkeleton({ modal }: { modal?: boolean }) { + const extraClasses = modal && 'border-none shadow-none'; + + return ( +
+ + + + + + + + + + + + + + + + + + + {[...Array(3)].map((_, i) => ( + + + + + + + + + + + + + + + ))} + +
+
+ ); +} diff --git a/resources/js/layouts/app/layout.tsx b/resources/js/layouts/app/layout.tsx index f6449854..cacf3cb2 100644 --- a/resources/js/layouts/app/layout.tsx +++ b/resources/js/layouts/app/layout.tsx @@ -3,9 +3,10 @@ import { AppHeader } from '@/components/app-header'; import { type BreadcrumbItem, NavItem, SharedData } from '@/types'; import { CSSProperties, type PropsWithChildren } from 'react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; -import { usePage, usePoll } from '@inertiajs/react'; +import { usePage } from '@inertiajs/react'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; export default function Layout({ children, @@ -16,8 +17,6 @@ export default function Layout({ secondNavItems?: NavItem[]; secondNavTitle?: string; }>) { - usePoll(10000); - const page = usePage(); if (page.props.flash && page.props.flash.success) toast.success(page.props.flash.success); @@ -25,21 +24,25 @@ export default function Layout({ if (page.props.flash && page.props.flash.info) toast.info(page.props.flash.info); if (page.props.flash && page.props.flash.warning) toast.error(page.props.flash.warning); + const queryClient = new QueryClient(); + return ( - 0)} - > - - - -
{children}
- -
-
+ + 0)} + > + + + +
{children}
+ +
+
+
); } diff --git a/resources/js/layouts/server/layout.tsx b/resources/js/layouts/server/layout.tsx index e4ab3f89..ba73e35d 100644 --- a/resources/js/layouts/server/layout.tsx +++ b/resources/js/layouts/server/layout.tsx @@ -35,7 +35,7 @@ export default function ServerLayout({ server, children }: { server: Server; chi }, { title: 'Backups', - href: route('database-backups', { server: server.id }), + href: route('backups', { server: server.id }), icon: CloudUploadIcon, }, ], diff --git a/resources/js/pages/database-backups/components/columns.tsx b/resources/js/pages/backups/components/columns.tsx similarity index 85% rename from resources/js/pages/database-backups/components/columns.tsx rename to resources/js/pages/backups/components/columns.tsx index 107a85ea..a18de8bb 100644 --- a/resources/js/pages/database-backups/components/columns.tsx +++ b/resources/js/pages/backups/components/columns.tsx @@ -10,7 +10,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Button } from '@/components/ui/button'; import { useForm } from '@inertiajs/react'; import { LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; @@ -18,13 +18,15 @@ import FormSuccessful from '@/components/form-successful'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Backup } from '@/types/backup'; +import BackupFiles from '@/pages/backups/components/files'; +import EditBackup from '@/pages/backups/components/edit-backup'; function Delete({ backup }: { backup: Backup }) { const [open, setOpen] = useState(false); const form = useForm(); const submit = () => { - form.delete(route('database-backups.destroy', { server: backup.server_id, backup: backup.id }), { + form.delete(route('backups.destroy', { server: backup.server_id, backup: backup.id }), { onSuccess: () => { setOpen(false); }, @@ -122,6 +124,13 @@ export const columns: ColumnDef[] = [ + + e.preventDefault()}>Edit + + + e.preventDefault()}>Files + +
diff --git a/resources/js/pages/database-backups/components/create-backup.tsx b/resources/js/pages/backups/components/create-backup.tsx similarity index 59% rename from resources/js/pages/database-backups/components/create-backup.tsx rename to resources/js/pages/backups/components/create-backup.tsx index 0ead0a8e..d965a72d 100644 --- a/resources/js/pages/database-backups/components/create-backup.tsx +++ b/resources/js/pages/backups/components/create-backup.tsx @@ -4,21 +4,17 @@ import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHe import { Form, FormField, FormFields } from '@/components/ui/form'; import { useForm, usePage } from '@inertiajs/react'; import { Button } from '@/components/ui/button'; -import { LoaderCircle, WifiIcon } from 'lucide-react'; +import { LoaderCircle } from 'lucide-react'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Database } from '@/types/database'; -import axios from 'axios'; import InputError from '@/components/ui/input-error'; -import { StorageProvider } from '@/types/storage-provider'; -import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider'; import { SharedData } from '@/types'; import { Input } from '@/components/ui/input'; +import DatabaseSelect from '@/pages/database-users/components/database-select'; +import StorageProviderSelect from '@/pages/storage-providers/components/storage-provider-select'; export default function CreateBackup({ server, children }: { server: Server; children: ReactNode }) { const [open, setOpen] = useState(false); - const [databases, setDatabases] = useState([]); - const [storageProviders, setStorageProviders] = useState([]); const page = usePage(); const form = useForm<{ @@ -37,87 +33,45 @@ export default function CreateBackup({ server, children }: { server: Server; chi const submit = (e: FormEvent) => { e.preventDefault(); - form.post(route('database-backups.store', { server: server.id }), { + form.post(route('backups.store', { server: server.id }), { onSuccess: () => { setOpen(false); }, }); }; - const onOpenChange = (open: boolean) => { - setOpen(open); - if (open) { - fetchDatabases(); - fetchStorageProviders(); - } - }; - - const fetchDatabases = () => { - axios.get(route('databases.json', { server: server.id })).then((response) => { - setDatabases(response.data); - }); - }; - - const fetchStorageProviders = () => { - axios.get(route('storage-providers.json')).then((response) => { - setStorageProviders(response.data); - }); - }; - return ( - + {children} Create backup Create a new backup -
+ {/*database*/} - + form.setData('database', value)} + /> {/*storage*/} -
- - fetchStorageProviders()}> - - -
+ form.setData('storage', value)} + />
@@ -166,7 +120,7 @@ export default function CreateBackup({ server, children }: { server: Server; chi
- diff --git a/resources/js/pages/backups/components/edit-backup.tsx b/resources/js/pages/backups/components/edit-backup.tsx new file mode 100644 index 00000000..20dacfa4 --- /dev/null +++ b/resources/js/pages/backups/components/edit-backup.tsx @@ -0,0 +1,113 @@ +import React, { FormEvent, ReactNode, useState } from 'react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { useForm, usePage } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircle } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import InputError from '@/components/ui/input-error'; +import { SharedData } from '@/types'; +import { Input } from '@/components/ui/input'; +import { Backup } from '@/types/backup'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; + +export default function EditBackup({ backup, children }: { backup: Backup; children: ReactNode }) { + const [open, setOpen] = useState(false); + const page = usePage(); + + const form = useForm<{ + interval: string; + custom_interval: string; + keep: string; + }>({ + interval: page.props.configs.cronjob_intervals[backup.interval] ? backup.interval : 'custom', + custom_interval: backup.interval, + keep: backup.keep_backups.toString(), + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.patch(route('backups.update', { server: backup.server_id, backup: backup.id }), { + onSuccess: () => { + setOpen(false); + }, + }); + }; + + return ( + + {children} + + + Create backup + Create a new backup + +
+ + {/*interval*/} + + + + + + + {/*custom interval*/} + {form.data.interval === 'custom' && ( + + + form.setData('custom_interval', e.target.value)} + placeholder="* * * * *" + /> + + + )} + + {/*backups to keep*/} + + + form.setData('keep', e.target.value)} /> + + + +
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/backups/components/files.tsx b/resources/js/pages/backups/components/files.tsx new file mode 100644 index 00000000..2ba96bfe --- /dev/null +++ b/resources/js/pages/backups/components/files.tsx @@ -0,0 +1,193 @@ +import { Backup } from '@/types/backup'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import React, { ReactNode, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { LoaderCircle, LoaderCircleIcon, MoreVerticalIcon } from 'lucide-react'; +import { useForm } from '@inertiajs/react'; +import { BackupFile } from '@/types/backup-file'; +import { ColumnDef } from '@tanstack/react-table'; +import DateTime from '@/components/date-time'; +import { Badge } from '@/components/ui/badge'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { DataTable } from '@/components/data-table'; +import axios from 'axios'; +import { useQuery } from '@tanstack/react-query'; +import { TableSkeleton } from '@/components/table-skeleton'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import RestoreBackup from '@/pages/backups/components/restore-backup'; + +function Delete({ file, onDeleted }: { file: BackupFile; onDeleted?: (file: BackupFile) => void }) { + const [open, setOpen] = useState(false); + const form = useForm(); + + const submit = () => { + form.delete( + route('backup-files.destroy', { + server: file.server_id, + backup: file.backup_id, + backupFile: file.id, + }), + { + onSuccess: () => { + setOpen(false); + if (onDeleted) { + onDeleted(file); + } + }, + }, + ); + }; + return ( + + + e.preventDefault()}> + Delete + + + + + Delete backup file + Delete backup file + +

Are you sure you want to this backup file?

+ + + + + + +
+
+ ); +} + +export default function BackupFiles({ backup, children }: { backup: Backup; children: ReactNode }) { + const [open, setOpen] = useState(false); + + const fetchFilesQuery = useQuery({ + queryKey: ['fetchFiles'], + queryFn: async () => { + const res = await axios.get( + route('backup-files', { + server: backup.server_id, + backup: backup.id, + }), + ); + return res.data; + }, + enabled: open, + }); + + const runBackupForm = useForm(); + const runBackup = () => { + runBackupForm.post(route('backups.run', { server: backup.server_id, backup: backup.id }), { + onSuccess: () => { + fetchFilesQuery.refetch(); + }, + }); + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'created_at', + header: 'Created at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: 'restored_to', + header: 'Restored to', + enableColumnFilter: true, + enableSorting: true, + }, + { + accessorKey: 'restored_at', + header: 'Restored at', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return row.original.restored_at ? : ''; + }, + }, + { + accessorKey: 'status', + header: 'Status', + enableColumnFilter: true, + enableSorting: true, + cell: ({ row }) => { + return {row.original.status}; + }, + }, + { + id: 'actions', + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => { + return ( +
+ + + + + + fetchFilesQuery.refetch()}> + e.preventDefault()}>Restore + + fetchFilesQuery.refetch()} /> + + +
+ ); + }, + }, + ]; + + return ( + + {children} + + + Backup files of [{backup.database.name}] + Backup files + + {fetchFilesQuery.isLoading && } + {fetchFilesQuery.isSuccess && !fetchFilesQuery.isLoading && } + +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/backups/components/restore-backup.tsx b/resources/js/pages/backups/components/restore-backup.tsx new file mode 100644 index 00000000..b3ee4aa6 --- /dev/null +++ b/resources/js/pages/backups/components/restore-backup.tsx @@ -0,0 +1,95 @@ +import { Backup } from '@/types/backup'; +import { BackupFile } from '@/types/backup-file'; +import { useForm } from '@inertiajs/react'; +import { FormEvent, ReactNode, useState } from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon } from 'lucide-react'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { Label } from '@/components/ui/label'; +import DatabaseSelect from '@/pages/database-users/components/database-select'; +import InputError from '@/components/ui/input-error'; + +export default function RestoreBackup({ + backup, + file, + onBackupRestored, + children, +}: { + backup: Backup; + file: BackupFile; + onBackupRestored?: () => void; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + + const form = useForm({ + database: '', + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post( + route('backup-files.restore', { + server: backup.server_id, + backup: backup.id, + backupFile: file.id, + }), + { + onSuccess: () => { + setOpen(false); + if (onBackupRestored) { + onBackupRestored(); + } + }, + }, + ); + }; + + return ( + + {children} + + + Restore backup + Restore backup + +
+ + + + form.setData('database', value)} + /> + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/database-backups/index.tsx b/resources/js/pages/backups/index.tsx similarity index 91% rename from resources/js/pages/database-backups/index.tsx rename to resources/js/pages/backups/index.tsx index 2389a076..f1d20369 100644 --- a/resources/js/pages/database-backups/index.tsx +++ b/resources/js/pages/backups/index.tsx @@ -9,8 +9,8 @@ import React from 'react'; import { BookOpenIcon, PlusIcon } from 'lucide-react'; import { Backup } from '@/types/backup'; import { DataTable } from '@/components/data-table'; -import { columns } from '@/pages/database-backups/components/columns'; -import CreateBackup from '@/pages/database-backups/components/create-backup'; +import { columns } from '@/pages/backups/components/columns'; +import CreateBackup from '@/pages/backups/components/create-backup'; type Page = { server: Server; diff --git a/resources/js/pages/database-users/components/database-select.tsx b/resources/js/pages/database-users/components/database-select.tsx new file mode 100644 index 00000000..aa35e7a6 --- /dev/null +++ b/resources/js/pages/database-users/components/database-select.tsx @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; +import { Database } from '@/types/database'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; + +export default function DatabaseSelect({ + serverId, + value, + onValueChange, + ...props +}: { + serverId: number; + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const databasesQuery = useQuery({ + queryKey: ['databases', serverId], + queryFn: async () => { + return (await axios.get(route('databases.json', { server: serverId }))).data; + }, + }); + + return ( + + ); +} diff --git a/resources/js/pages/server-providers/components/connect-server-provider.tsx b/resources/js/pages/server-providers/components/connect-server-provider.tsx index fa9526c1..1fb088b8 100644 --- a/resources/js/pages/server-providers/components/connect-server-provider.tsx +++ b/resources/js/pages/server-providers/components/connect-server-provider.tsx @@ -27,12 +27,10 @@ type ServerProviderForm = { }; export default function ConnectServerProvider({ - providers, defaultProvider, onProviderAdded, children, }: { - providers: string[]; defaultProvider?: string; onProviderAdded?: () => void; children: ReactNode; @@ -62,7 +60,7 @@ export default function ConnectServerProvider({ return ( {children} - + Connect to server provider Connect to a new server provider @@ -83,7 +81,7 @@ export default function ConnectServerProvider({ - {providers.map( + {page.props.configs.server_providers.map( (provider) => provider !== 'custom' && ( @@ -108,21 +106,25 @@ export default function ConnectServerProvider({ /> - {page.props.configs.server_providers_custom_fields[form.data.provider]?.map((item: string) => ( - - - form.setData(item as keyof ServerProviderForm, e.target.value)} - /> - - - ))} +
1 ? 'grid grid-cols-2 items-start gap-6' : ''} + > + {page.props.configs.server_providers_custom_fields[form.data.provider]?.map((item: string) => ( + + + form.setData(item as keyof ServerProviderForm, e.target.value)} + /> + + + ))} +
form.setData('global', !form.data.global)} /> diff --git a/resources/js/pages/server-providers/components/server-provider-select.tsx b/resources/js/pages/server-providers/components/server-provider-select.tsx new file mode 100644 index 00000000..78c01dd9 --- /dev/null +++ b/resources/js/pages/server-providers/components/server-provider-select.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; +import { ServerProvider } from '@/types/server-provider'; +import ConnectServerProvider from '@/pages/server-providers/components/connect-server-provider'; +import { Button } from '@/components/ui/button'; +import { WifiIcon } from 'lucide-react'; + +export default function ServerProviderSelect({ + value, + onValueChange, + ...props +}: { + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const query = useQuery({ + queryKey: ['serverProvider'], + queryFn: async () => { + return (await axios.get(route('server-providers.json'))).data; + }, + }); + + return ( +
+ + query.refetch()}> + + +
+ ); +} diff --git a/resources/js/pages/server-providers/index.tsx b/resources/js/pages/server-providers/index.tsx index f162ef3a..2ce1d506 100644 --- a/resources/js/pages/server-providers/index.tsx +++ b/resources/js/pages/server-providers/index.tsx @@ -28,7 +28,7 @@ export default function ServerProviders() {
- +
diff --git a/resources/js/pages/servers/components/create-server.tsx b/resources/js/pages/servers/components/create-server.tsx index 602792f4..accbe235 100644 --- a/resources/js/pages/servers/components/create-server.tsx +++ b/resources/js/pages/servers/components/create-server.tsx @@ -1,6 +1,6 @@ import { ClipboardCheckIcon, ClipboardIcon, LoaderCircle, TriangleAlert, WifiIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; +import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'; import { useForm, usePage } from '@inertiajs/react'; import React, { FormEventHandler, useState } from 'react'; import { Label } from '@/components/ui/label'; @@ -150,11 +150,7 @@ export default function CreateServer({ children }: { children: React.ReactNode } - item !== 'custom')} - defaultProvider={form.data.provider} - onProviderAdded={fetchServerProviders} - > + @@ -339,10 +335,15 @@ export default function CreateServer({ children }: { children: React.ReactNode } -
+
+ + +
diff --git a/resources/js/pages/servers/components/delete-server.tsx b/resources/js/pages/servers/components/delete-server.tsx index 17c82f20..56ad267b 100644 --- a/resources/js/pages/servers/components/delete-server.tsx +++ b/resources/js/pages/servers/components/delete-server.tsx @@ -34,13 +34,18 @@ export default function DeleteServer({ server, children }: { server: Server; chi Delete {server.name} - Delete server and its resources. + Delete server and its resources. +

+ Are you sure you want to delete this server: {server.name}? All resources associated with this server will be deleted and + this action cannot be undone. +

+
- + form.setData('name', e.target.value)} /> diff --git a/resources/js/pages/servers/components/header.tsx b/resources/js/pages/servers/components/header.tsx index dd3dbe0e..ca2f5eb5 100644 --- a/resources/js/pages/servers/components/header.tsx +++ b/resources/js/pages/servers/components/header.tsx @@ -17,7 +17,7 @@ export default function ServerHeader({ server }: { server: Server }) {
{server.name}
- + {server.name} Server Name @@ -30,7 +30,7 @@ export default function ServerHeader({ server }: { server: Server }) {
{server.provider}
- +
{server.provider} Server Provider @@ -45,7 +45,7 @@ export default function ServerHeader({ server }: { server: Server }) {
{server.ip}
- + {server.ip} Server IP @@ -60,7 +60,7 @@ export default function ServerHeader({ server }: { server: Server }) {
%{parseInt(server.progress || '0')}
- Installation Progress + Installation Progress )} diff --git a/resources/js/pages/storage-providers/components/storage-provider-select.tsx b/resources/js/pages/storage-providers/components/storage-provider-select.tsx new file mode 100644 index 00000000..6fe68b87 --- /dev/null +++ b/resources/js/pages/storage-providers/components/storage-provider-select.tsx @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import React from 'react'; +import { SelectTriggerProps } from '@radix-ui/react-select'; +import { StorageProvider } from '@/types/storage-provider'; +import ConnectStorageProvider from '@/pages/storage-providers/components/connect-storage-provider'; +import { Button } from '@/components/ui/button'; +import { WifiIcon } from 'lucide-react'; + +export default function StorageProviderSelect({ + value, + onValueChange, + ...props +}: { + value: string; + onValueChange: (value: string) => void; +} & SelectTriggerProps) { + const query = useQuery({ + queryKey: ['storageProvider'], + queryFn: async () => { + return (await axios.get(route('storage-providers.json'))).data; + }, + }); + + return ( +
+ + query.refetch()}> + + +
+ ); +} diff --git a/resources/js/types/backup-file.d.ts b/resources/js/types/backup-file.d.ts index 6c4e75a9..51730c88 100644 --- a/resources/js/types/backup-file.d.ts +++ b/resources/js/types/backup-file.d.ts @@ -1,6 +1,7 @@ export interface BackupFile { id: number; backup_id: number; + server_id: number; name: string; size: number; restored_to: string; diff --git a/tests/Feature/DatabaseBackupTest.php b/tests/Feature/DatabaseBackupTest.php index ef22b65d..b47d2bf4 100644 --- a/tests/Feature/DatabaseBackupTest.php +++ b/tests/Feature/DatabaseBackupTest.php @@ -9,13 +9,10 @@ use App\Models\Backup; use App\Models\Database; use App\Models\StorageProvider; -use App\Web\Pages\Servers\Databases\Backups; -use App\Web\Pages\Servers\Databases\Widgets\BackupFilesList; -use App\Web\Pages\Servers\Databases\Widgets\BackupsList; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Http; -use Livewire\Livewire; +use JsonException; use Tests\TestCase; class DatabaseBackupTest extends TestCase @@ -24,6 +21,8 @@ class DatabaseBackupTest extends TestCase /** * @dataProvider data + * + * @throws JsonException */ public function test_create_backup(string $db): void { @@ -43,14 +42,15 @@ public function test_create_backup(string $db): void 'provider' => \App\Enums\StorageProvider::DROPBOX, ]); - Livewire::test(Backups::class, ['server' => $this->server]) - ->callAction('create', [ - 'database' => $database->id, - 'storage' => $storage->id, - 'interval' => '0 * * * *', - 'keep' => '10', - ]) - ->assertSuccessful(); + $this->post(route('backups.store', [ + 'server' => $this->server, + ]), [ + 'database' => $database->id, + 'storage' => $storage->id, + 'interval' => '0 * * * *', + 'keep' => '10', + ]) + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('backups', [ 'status' => BackupStatus::RUNNING, @@ -61,6 +61,9 @@ public function test_create_backup(string $db): void ]); } + /** + * @throws JsonException + */ public function test_create_custom_interval_backup(): void { Bus::fake(); @@ -76,15 +79,14 @@ public function test_create_custom_interval_backup(): void 'provider' => \App\Enums\StorageProvider::DROPBOX, ]); - Livewire::test(Backups::class, ['server' => $this->server]) - ->callAction('create', [ - 'database' => $database->id, - 'storage' => $storage->id, - 'interval' => 'custom', - 'custom_interval' => '* * * * *', - 'keep' => '10', - ]) - ->assertSuccessful(); + $this->post(route('backups.store', ['server' => $this->server]), [ + 'database' => $database->id, + 'storage' => $storage->id, + 'interval' => 'custom', + 'custom_interval' => '* * * * *', + 'keep' => '10', + ]) + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('backups', [ 'status' => BackupStatus::RUNNING, @@ -104,22 +106,19 @@ public function test_see_backups_list(): void 'provider' => \App\Enums\StorageProvider::DROPBOX, ]); - $backup = Backup::factory()->create([ + Backup::factory()->create([ 'server_id' => $this->server->id, 'database_id' => $database->id, 'storage_id' => $storage->id, ]); - $this->get( - Backups::getUrl([ - 'server' => $this->server, - 'backup' => $backup, - ]) - ) - ->assertSuccessful() - ->assertSee($backup->database->name); + $this->get(route('backups', ['server' => $this->server])) + ->assertSuccessful(); } + /** + * @throws JsonException + */ public function test_update_backup(): void { $this->actingAs($this->user); @@ -141,14 +140,14 @@ public function test_update_backup(): void 'keep_backups' => 5, ]); - Livewire::test(BackupsList::class, [ + $this->patch(route('backups.update', [ 'server' => $this->server, + 'backup' => $backup, + ]), [ + 'interval' => '0 0 * * *', + 'keep' => 10, ]) - ->callTableAction('edit', $backup->id, [ - 'interval' => '0 0 * * *', - 'keep' => '10', - ]) - ->assertSuccessful(); + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('backups', [ 'id' => $backup->id, @@ -159,6 +158,8 @@ public function test_update_backup(): void /** * @dataProvider data + * + * @throws JsonException */ public function test_delete_backup(string $db): void { @@ -181,11 +182,8 @@ public function test_delete_backup(string $db): void 'storage_id' => $storage->id, ]); - Livewire::test(BackupsList::class, [ - 'server' => $this->server, - ]) - ->callTableAction('delete', $backup->id) - ->assertSuccessful(); + $this->delete(route('backups.destroy', ['server' => $this->server, 'backup' => $backup])) + ->assertSessionHasNoErrors(); $this->assertDatabaseMissing('backups', [ 'id' => $backup->id, @@ -194,6 +192,8 @@ public function test_delete_backup(string $db): void /** * @dataProvider data + * + * @throws JsonException */ public function test_restore_backup(string $db): void { @@ -221,14 +221,14 @@ public function test_restore_backup(string $db): void $backupFile = app(RunBackup::class)->run($backup); - Livewire::test(BackupFilesList::class, [ + $this->post(route('backup-files.restore', [ 'server' => $this->server, 'backup' => $backup, + 'backupFile' => $backupFile, + ]), [ + 'database' => $database->id, ]) - ->callTableAction('restore', $backupFile->id, [ - 'database' => $database->id, - ]) - ->assertSuccessful(); + ->assertSessionHasNoErrors(); $this->assertDatabaseHas('backup_files', [ 'id' => $backupFile->id, diff --git a/vite.config.ts b/vite.config.ts index 290d90e8..47f748ce 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,21 +5,21 @@ import { resolve } from 'node:path'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.tsx'], - ssr: 'resources/js/ssr.tsx', - refresh: true, - }), - react(), - tailwindcss(), - ], - esbuild: { - jsx: 'automatic', - }, - resolve: { - alias: { - 'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'), - }, + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.tsx'], + ssr: 'resources/js/ssr.tsx', + refresh: true, + }), + react(), + tailwindcss(), + ], + esbuild: { + jsx: 'automatic', + }, + resolve: { + alias: { + 'ziggy-js': resolve(__dirname, 'vendor/tightenco/ziggy'), }, + }, });