From 2e9620409b44aeb74d18ed9f98c8e6670ee1beee Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 29 Sep 2024 17:54:11 +0200 Subject: [PATCH] 2.x - backups --- app/Actions/Database/CreateBackup.php | 51 ++--- app/Actions/Database/RestoreBackup.php | 18 +- app/Actions/Database/RunBackup.php | 17 +- .../StorageProvider/CreateStorageProvider.php | 24 +-- .../StorageProvider/EditStorageProvider.php | 8 +- app/Enums/BackupStatus.php | 4 - app/Models/Backup.php | 14 +- app/Models/BackupFile.php | 3 +- app/Models/StorageProvider.php | 6 + app/Policies/BackupFilePolicy.php | 41 ++++ app/Policies/StorageProviderPolicy.php | 37 ++++ app/Providers/WebServiceProvider.php | 1 + app/StorageProviders/FTP.php | 7 +- app/StorageProviders/S3.php | 10 +- app/StorageProviders/Wasabi.php | 10 +- app/Web/Components/Link.php | 26 +++ app/Web/Contracts/HasSecondSubNav.php | 8 + app/Web/Pages/Servers/Databases/Backups.php | 78 ++++++- app/Web/Pages/Servers/Databases/Index.php | 3 +- .../Servers/Databases/Traits/Navigation.php | 4 +- app/Web/Pages/Servers/Databases/Users.php | 3 +- .../Databases/Widgets/BackupFilesList.php | 118 ++++++++++ .../Servers/Databases/Widgets/BackupsList.php | 113 ++++++++++ app/Web/Pages/Servers/Index.php | 202 +++++++++++++++++- .../Pages/Servers/Widgets/CreateServer.php | 45 ++-- .../Pages/Servers/Widgets/ServerDetails.php | 12 +- .../StorageProviders/Actions/Create.php | 139 ++++++++++++ .../StorageProviders/Actions/Edit.php | 27 +++ .../Pages/Settings/StorageProviders/Index.php | 49 +++++ .../Widgets/StorageProvidersList.php | 77 +++++++ config/core.php | 9 + resources/css/filament/app/theme.css | 5 + .../views/web/components/brand.blade.php | 44 +++- .../web/components/dynamic-widget.blade.php | 1 + resources/views/web/components/link.blade.php | 1 + 35 files changed, 1093 insertions(+), 122 deletions(-) create mode 100644 app/Policies/BackupFilePolicy.php create mode 100644 app/Policies/StorageProviderPolicy.php create mode 100644 app/Web/Components/Link.php create mode 100644 app/Web/Contracts/HasSecondSubNav.php create mode 100644 app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php create mode 100644 app/Web/Pages/Servers/Databases/Widgets/BackupsList.php create mode 100644 app/Web/Pages/Settings/StorageProviders/Actions/Create.php create mode 100644 app/Web/Pages/Settings/StorageProviders/Actions/Edit.php create mode 100644 app/Web/Pages/Settings/StorageProviders/Index.php create mode 100644 app/Web/Pages/Settings/StorageProviders/Widgets/StorageProvidersList.php create mode 100644 resources/views/web/components/dynamic-widget.blade.php create mode 100644 resources/views/web/components/link.blade.php diff --git a/app/Actions/Database/CreateBackup.php b/app/Actions/Database/CreateBackup.php index f3057fc..7ece8f3 100644 --- a/app/Actions/Database/CreateBackup.php +++ b/app/Actions/Database/CreateBackup.php @@ -7,7 +7,6 @@ use App\Models\Backup; use App\Models\Server; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -17,17 +16,15 @@ class CreateBackup * @throws AuthorizationException * @throws ValidationException */ - public function create($type, Server $server, array $input): Backup + public function create(Server $server, array $input): Backup { - $this->validate($type, $server, $input); - $backup = new Backup([ - 'type' => $type, + 'type' => 'database', 'server_id' => $server->id, - 'database_id' => $input['backup_database'] ?? null, - 'storage_id' => $input['backup_storage'], - 'interval' => $input['backup_interval'] == 'custom' ? $input['backup_custom'] : $input['backup_interval'], - 'keep_backups' => $input['backup_keep'], + 'database_id' => $input['database'] ?? null, + 'storage_id' => $input['storage'], + 'interval' => $input['interval'] == 'custom' ? $input['custom_interval'] : $input['interval'], + 'keep_backups' => $input['keep'], 'status' => BackupStatus::RUNNING, ]); $backup->save(); @@ -37,45 +34,35 @@ public function create($type, Server $server, array $input): Backup return $backup; } - /** - * @throws ValidationException - */ - private function validate($type, Server $server, array $input): void + public static function rules(Server $server, array $input): array { $rules = [ - 'backup_storage' => [ + 'storage' => [ 'required', Rule::exists('storage_providers', 'id'), ], - 'backup_keep' => [ + 'keep' => [ 'required', 'numeric', 'min:1', ], - 'backup_interval' => [ + 'interval' => [ 'required', - Rule::in([ - '0 * * * *', - '0 0 * * *', - '0 0 * * 0', - '0 0 1 * *', - 'custom', - ]), + Rule::in(array_keys(config('core.cronjob_intervals'))), ], - ]; - if ($input['backup_interval'] == 'custom') { - $rules['backup_custom'] = [ - 'required', - ]; - } - if ($type === 'database') { - $rules['backup_database'] = [ + 'database' => [ 'required', Rule::exists('databases', 'id') ->where('server_id', $server->id) ->where('status', DatabaseStatus::READY), + ], + ]; + if ($input['interval'] == 'custom') { + $rules['custom_interval'] = [ + 'required', ]; } - Validator::make($input, $rules)->validate(); + + return $rules; } } diff --git a/app/Actions/Database/RestoreBackup.php b/app/Actions/Database/RestoreBackup.php index 2c9bea6..17de5c7 100644 --- a/app/Actions/Database/RestoreBackup.php +++ b/app/Actions/Database/RestoreBackup.php @@ -5,14 +5,11 @@ use App\Enums\BackupFileStatus; use App\Models\BackupFile; use App\Models\Database; -use Illuminate\Support\Facades\Validator; class RestoreBackup { public function restore(BackupFile $backupFile, array $input): void { - $this->validate($input); - /** @var Database $database */ $database = Database::query()->findOrFail($input['database']); $backupFile->status = BackupFileStatus::RESTORING; @@ -20,7 +17,9 @@ public function restore(BackupFile $backupFile, array $input): void $backupFile->save(); dispatch(function () use ($backupFile, $database) { - $database->server->database()->handler()->restoreBackup($backupFile, $database->name); + /** @var \App\SSH\Services\Database\Database $databaseHandler */ + $databaseHandler = $database->server->database()->handler(); + $databaseHandler->restoreBackup($backupFile, $database->name); $backupFile->status = BackupFileStatus::RESTORED; $backupFile->restored_at = now(); $backupFile->save(); @@ -30,10 +29,13 @@ public function restore(BackupFile $backupFile, array $input): void })->onConnection('ssh'); } - private function validate(array $input): void + public static function rules(): array { - Validator::make($input, [ - 'database' => 'required|exists:databases,id', - ])->validate(); + return [ + 'database' => [ + 'required', + 'exists:databases,id', + ], + ]; } } diff --git a/app/Actions/Database/RunBackup.php b/app/Actions/Database/RunBackup.php index 7ea949d..6819c91 100644 --- a/app/Actions/Database/RunBackup.php +++ b/app/Actions/Database/RunBackup.php @@ -3,8 +3,10 @@ namespace App\Actions\Database; use App\Enums\BackupFileStatus; +use App\Enums\BackupStatus; use App\Models\Backup; use App\Models\BackupFile; +use App\SSH\Services\Database\Database; use Illuminate\Support\Str; class RunBackup @@ -18,11 +20,20 @@ public function run(Backup $backup): BackupFile ]); $file->save(); - dispatch(function () use ($file) { - $file->backup->server->database()->handler()->runBackup($file); + dispatch(function () use ($file, $backup) { + /** @var Database $databaseHandler */ + $databaseHandler = $file->backup->server->database()->handler(); + $databaseHandler->runBackup($file); $file->status = BackupFileStatus::CREATED; $file->save(); - })->catch(function () use ($file) { + + if ($backup->status !== BackupStatus::RUNNING) { + $backup->status = BackupStatus::RUNNING; + $backup->save(); + } + })->catch(function () use ($file, $backup) { + $backup->status = BackupStatus::FAILED; + $backup->save(); $file->status = BackupFileStatus::FAILED; $file->save(); })->onConnection('ssh'); diff --git a/app/Actions/StorageProvider/CreateStorageProvider.php b/app/Actions/StorageProvider/CreateStorageProvider.php index 7fcbad7..a0d147a 100644 --- a/app/Actions/StorageProvider/CreateStorageProvider.php +++ b/app/Actions/StorageProvider/CreateStorageProvider.php @@ -4,7 +4,6 @@ use App\Models\StorageProvider; use App\Models\User; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -15,8 +14,6 @@ class CreateStorageProvider */ public function create(User $user, array $input): void { - $this->validate($user, $input); - $storageProvider = new StorageProvider([ 'user_id' => $user->id, 'provider' => $input['provider'], @@ -24,8 +21,6 @@ public function create(User $user, array $input): void 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, ]); - $this->validateProvider($input, $storageProvider->provider()->validationRules()); - $storageProvider->credentials = $storageProvider->provider()->credentialData($input); try { @@ -43,22 +38,25 @@ public function create(User $user, array $input): void $storageProvider->save(); } - private function validate(User $user, array $input): void + public static function rules(array $input): array { - Validator::make($input, [ + $rules = [ 'provider' => [ 'required', Rule::in(config('core.storage_providers')), ], 'name' => [ 'required', - Rule::unique('storage_providers', 'profile')->where('user_id', $user->id), ], - ])->validate(); - } + ]; - private function validateProvider(array $input, array $rules): void - { - Validator::make($input, $rules)->validate(); + if (isset($input['provider'])) { + $provider = (new StorageProvider(['provider' => $input['provider']]))->provider(); + $rules = array_merge($rules, $provider->validationRules()); + } + + ds($rules); + + return $rules; } } diff --git a/app/Actions/StorageProvider/EditStorageProvider.php b/app/Actions/StorageProvider/EditStorageProvider.php index 0d33c80..565bff0 100644 --- a/app/Actions/StorageProvider/EditStorageProvider.php +++ b/app/Actions/StorageProvider/EditStorageProvider.php @@ -4,15 +4,12 @@ use App\Models\StorageProvider; use App\Models\User; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class EditStorageProvider { public function edit(StorageProvider $storageProvider, User $user, array $input): void { - $this->validate($input); - $storageProvider->profile = $input['name']; $storageProvider->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id; @@ -22,13 +19,12 @@ public function edit(StorageProvider $storageProvider, User $user, array $input) /** * @throws ValidationException */ - private function validate(array $input): void + public static function rules(): array { - $rules = [ + return [ 'name' => [ 'required', ], ]; - Validator::make($input, $rules)->validate(); } } diff --git a/app/Enums/BackupStatus.php b/app/Enums/BackupStatus.php index 3444660..85525e1 100644 --- a/app/Enums/BackupStatus.php +++ b/app/Enums/BackupStatus.php @@ -4,11 +4,7 @@ final class BackupStatus { - const READY = 'ready'; - const RUNNING = 'running'; const FAILED = 'failed'; - - const DELETING = 'deleting'; } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index bfdcc75..7972c17 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property string $type @@ -46,14 +47,14 @@ public static function boot(): void parent::boot(); static::deleting(function (Backup $backup) { - $backup->files()->delete(); + $backup->files()->each(function (BackupFile $file) { + $file->delete(); + }); }); } public static array $statusColors = [ - BackupStatus::READY => 'success', - BackupStatus::RUNNING => 'warning', - BackupStatus::DELETING => 'warning', + BackupStatus::RUNNING => 'success', BackupStatus::FAILED => 'danger', ]; @@ -76,4 +77,9 @@ public function files(): HasMany { return $this->hasMany(BackupFile::class, 'backup_id'); } + + public function lastFile(): HasOne + { + return $this->hasOne(BackupFile::class, 'backup_id')->latest(); + } } diff --git a/app/Models/BackupFile.php b/app/Models/BackupFile.php index 8cff05e..b8c60db 100644 --- a/app/Models/BackupFile.php +++ b/app/Models/BackupFile.php @@ -66,7 +66,8 @@ protected static function booted(): void BackupFileStatus::CREATING => 'warning', BackupFileStatus::FAILED => 'danger', BackupFileStatus::DELETING => 'warning', - BackupFileStatus::RESTORED => 'warning', + BackupFileStatus::RESTORING => 'warning', + BackupFileStatus::RESTORED => 'primary', BackupFileStatus::RESTORE_FAILED => 'danger', ]; diff --git a/app/Models/StorageProvider.php b/app/Models/StorageProvider.php index a45d650..e4ccfdf 100644 --- a/app/Models/StorageProvider.php +++ b/app/Models/StorageProvider.php @@ -14,6 +14,7 @@ * @property array $credentials * @property User $user * @property int $project_id + * @property string $image_url */ class StorageProvider extends AbstractModel { @@ -61,4 +62,9 @@ public static function getByProjectId(int $projectId): Builder ->where('project_id', $projectId) ->orWhereNull('project_id'); } + + public function getImageUrlAttribute(): string + { + return url('/static/images/'.$this->provider.'.svg'); + } } diff --git a/app/Policies/BackupFilePolicy.php b/app/Policies/BackupFilePolicy.php new file mode 100644 index 0000000..529b5be --- /dev/null +++ b/app/Policies/BackupFilePolicy.php @@ -0,0 +1,41 @@ +isAdmin() || $backup->server->project->users->contains($user)) && $backup->server->isReady(); + } + + public function view(User $user, BackupFile $backupFile): bool + { + return ($user->isAdmin() || $backupFile->backup->server->project->users->contains($user)) && + $backupFile->backup->server->isReady(); + } + + public function create(User $user, Backup $backup): bool + { + return ($user->isAdmin() || $backup->server->project->users->contains($user)) && $backup->server->isReady(); + } + + public function update(User $user, BackupFile $backupFile): bool + { + return ($user->isAdmin() || $backupFile->backup->server->project->users->contains($user)) && + $backupFile->backup->server->isReady(); + } + + public function delete(User $user, BackupFile $backupFile): bool + { + return ($user->isAdmin() || $backupFile->backup->server->project->users->contains($user)) && + $backupFile->backup->server->isReady(); + } +} diff --git a/app/Policies/StorageProviderPolicy.php b/app/Policies/StorageProviderPolicy.php new file mode 100644 index 0000000..6e6ab5c --- /dev/null +++ b/app/Policies/StorageProviderPolicy.php @@ -0,0 +1,37 @@ +isAdmin(); + } + + public function view(User $user, StorageProvider $storageProvider): bool + { + return $user->isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function update(User $user, StorageProvider $storageProvider): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, StorageProvider $storageProvider): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Providers/WebServiceProvider.php b/app/Providers/WebServiceProvider.php index e38aea4..dddf071 100644 --- a/app/Providers/WebServiceProvider.php +++ b/app/Providers/WebServiceProvider.php @@ -93,6 +93,7 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]) + ->login() ->spa() ->globalSearchKeyBindings(['command+k', 'ctrl+k']) ->globalSearchFieldKeyBindingSuffix(); diff --git a/app/StorageProviders/FTP.php b/app/StorageProviders/FTP.php index 53ea596..ba9bdf2 100644 --- a/app/StorageProviders/FTP.php +++ b/app/StorageProviders/FTP.php @@ -12,7 +12,12 @@ public function validationRules(): array { return [ 'host' => 'required', - 'port' => 'required|numeric', + 'port' => [ + 'required', + 'integer', + 'min:1', + 'max:65535', + ], 'path' => 'required', 'username' => 'required', 'password' => 'required', diff --git a/app/StorageProviders/S3.php b/app/StorageProviders/S3.php index 62ff5d7..d28100d 100644 --- a/app/StorageProviders/S3.php +++ b/app/StorageProviders/S3.php @@ -13,11 +13,11 @@ class S3 extends S3AbstractStorageProvider public function validationRules(): array { return [ - 'key' => 'required|string', - 'secret' => 'required|string', - 'region' => 'required|string', - 'bucket' => 'required|string', - 'path' => 'required|string', + 'key' => 'required', + 'secret' => 'required', + 'region' => 'required', + 'bucket' => 'required', + 'path' => 'required', ]; } diff --git a/app/StorageProviders/Wasabi.php b/app/StorageProviders/Wasabi.php index acfc544..e97e59c 100644 --- a/app/StorageProviders/Wasabi.php +++ b/app/StorageProviders/Wasabi.php @@ -15,11 +15,11 @@ class Wasabi extends S3AbstractStorageProvider public function validationRules(): array { return [ - 'key' => 'required|string', - 'secret' => 'required|string', - 'region' => 'required|string', - 'bucket' => 'required|string', - 'path' => 'required|string', + 'key' => 'required', + 'secret' => 'required', + 'region' => 'required', + 'bucket' => 'required', + 'path' => 'required', ]; } diff --git a/app/Web/Components/Link.php b/app/Web/Components/Link.php new file mode 100644 index 0000000..073ae88 --- /dev/null +++ b/app/Web/Components/Link.php @@ -0,0 +1,26 @@ +render()->with([ + 'href' => $this->href, + 'text' => $this->text, + 'external' => $this->external, + ]); + } +} diff --git a/app/Web/Contracts/HasSecondSubNav.php b/app/Web/Contracts/HasSecondSubNav.php new file mode 100644 index 0000000..6529ee5 --- /dev/null +++ b/app/Web/Contracts/HasSecondSubNav.php @@ -0,0 +1,8 @@ +icon('heroicon-o-plus') + ->modalWidth(MaxWidth::Large) + ->authorize(fn () => auth()->user()?->can('create', [Backup::class, $this->server])) + ->form([ + Select::make('database') + ->label('Database') + ->options($this->server->databases()->pluck('name', 'id')->toArray()) + ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['database']) + ->searchable(), + Select::make('storage') + ->label('Storage') + ->options(StorageProvider::getByProjectId($this->server->project_id)->pluck('profile', 'id')->toArray()) + ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['storage']) + ->suffixAction( + \Filament\Forms\Components\Actions\Action::make('connect') + ->form(Create::form()) + ->modalHeading('Connect to a new storage provider') + ->modalSubmitActionLabel('Connect') + ->icon('heroicon-o-wifi') + ->tooltip('Connect to a new storage provider') + ->modalWidth(MaxWidth::Medium) + ->authorize(fn () => auth()->user()->can('create', StorageProvider::class)) + ->action(fn (array $data) => Create::action($data)) + ), + Select::make('interval') + ->label('Interval') + ->options(config('core.cronjob_intervals')) + ->reactive() + ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['interval']), + TextInput::make('custom_interval') + ->label('Custom Interval (Cron)') + ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['custom_interval']) + ->visible(fn (callable $get) => $get('interval') === 'custom') + ->placeholder('0 * * * *'), + TextInput::make('keep') + ->label('Backups to Keep') + ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['keep']) + ->helperText('How many backups to keep before deleting the oldest one'), + ]) + ->modalSubmitActionLabel('Create') + ->action(function (array $data) { + try { + app(CreateBackup::class)->create($this->server, $data); + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Backup created!') + ->send(); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + }), + ]; + } + + public function getWidgets(): array + { + return [ + [Widgets\BackupsList::class, ['server' => $this->server]], ]; } } diff --git a/app/Web/Pages/Servers/Databases/Index.php b/app/Web/Pages/Servers/Databases/Index.php index ee2b5d3..28b6638 100644 --- a/app/Web/Pages/Servers/Databases/Index.php +++ b/app/Web/Pages/Servers/Databases/Index.php @@ -6,6 +6,7 @@ use App\Models\Database; use App\Models\Server; use App\Web\Components\Page; +use App\Web\Contracts\HasSecondSubNav; use App\Web\Traits\PageHasServer; use Exception; use Filament\Actions\Action; @@ -14,7 +15,7 @@ use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; -class Index extends Page +class Index extends Page implements HasSecondSubNav { use PageHasServer; use Traits\Navigation; diff --git a/app/Web/Pages/Servers/Databases/Traits/Navigation.php b/app/Web/Pages/Servers/Databases/Traits/Navigation.php index 2cfdc07..0957b7a 100644 --- a/app/Web/Pages/Servers/Databases/Traits/Navigation.php +++ b/app/Web/Pages/Servers/Databases/Traits/Navigation.php @@ -10,8 +10,6 @@ trait Navigation { - public bool $hasSecondSubNavigation = true; - public function getSecondSubNavigation(): array { $items = []; @@ -32,7 +30,7 @@ public function getSecondSubNavigation(): array if (Backups::canAccess()) { $items[] = NavigationItem::make(Backups::getNavigationLabel()) - ->icon('heroicon-o-circle-stack') + ->icon('heroicon-o-cloud') ->isActiveWhen(fn () => request()->routeIs(Backups::getRouteName())) ->url(Backups::getUrl(parameters: ['server' => $this->server])); } diff --git a/app/Web/Pages/Servers/Databases/Users.php b/app/Web/Pages/Servers/Databases/Users.php index 522f758..5712d86 100644 --- a/app/Web/Pages/Servers/Databases/Users.php +++ b/app/Web/Pages/Servers/Databases/Users.php @@ -7,6 +7,7 @@ use App\Models\DatabaseUser; use App\Models\Server; use App\Web\Components\Page; +use App\Web\Contracts\HasSecondSubNav; use App\Web\Traits\PageHasServer; use Exception; use Filament\Actions\Action; @@ -15,7 +16,7 @@ use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; -class Users extends Page +class Users extends Page implements HasSecondSubNav { use PageHasServer; use Traits\Navigation; diff --git a/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php new file mode 100644 index 0000000..286a297 --- /dev/null +++ b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php @@ -0,0 +1,118 @@ +where('backup_id', $this->backup->id); + } + + protected static ?string $heading = ''; + + protected function getTableColumns(): array + { + return [ + TextColumn::make('name') + ->searchable(), + TextColumn::make('created_at') + ->formatStateUsing(fn (BackupFile $record) => $record->created_at_by_timezone) + ->sortable(), + TextColumn::make('restored_to') + ->searchable(), + TextColumn::make('restored_at') + ->formatStateUsing(fn (BackupFile $record) => $record->getDateTimeByTimezone($record->restored_at)) + ->sortable(), + TextColumn::make('status') + ->badge() + ->color(fn ($state) => BackupFile::$statusColors[$state]) + ->sortable(), + + ]; + } + + protected function applyDefaultSortingToTableQuery(Builder $query): Builder + { + return $query->latest('created_at'); + } + + public function getTable(): Table + { + return $this->table + ->actions([ + Action::make('restore') + ->hiddenLabel() + ->icon('heroicon-o-arrow-path') + ->modalHeading('Restore Backup') + ->tooltip('Restore Backup') + ->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup->database)) + ->form([ + Select::make('database') + ->label('Restore to') + ->options($this->backup->server->databases()->pluck('name', 'id')) + ->rules(RestoreBackup::rules()['database']) + ->native(false), + ]) + ->modalWidth(MaxWidth::Large) + ->action(function (BackupFile $record, array $data) { + try { + app(RestoreBackup::class)->restore($record, $data); + + Notification::make() + ->success() + ->title('Backup is being restored') + ->send(); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + + $this->dispatch('$refresh'); + }), + Action::make('delete') + ->hiddenLabel() + ->icon('heroicon-o-trash') + ->modalHeading('Delete Database') + ->color('danger') + ->tooltip('Delete') + ->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record)) + ->requiresConfirmation() + ->action(function (BackupFile $record) { + try { + $record->delete(); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + + $this->dispatch('$refresh'); + }), + ]); + } +} diff --git a/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php b/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php new file mode 100644 index 0000000..99e6e2f --- /dev/null +++ b/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php @@ -0,0 +1,113 @@ +where('server_id', $this->server->id); + } + + protected static ?string $heading = ''; + + protected function getTableColumns(): array + { + return [ + TextColumn::make('database.name') + ->label('Database') + ->searchable(), + TextColumn::make('storage.profile') + ->label('Storage') + ->searchable(), + TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (Backup $backup) => Backup::$statusColors[$backup->status]) + ->sortable(), + TextColumn::make('lastFile.status') + ->label('Last file status') + ->badge() + ->color(fn ($state) => BackupFile::$statusColors[$state]) + ->sortable(), + TextColumn::make('created_at') + ->label('Created At') + ->formatStateUsing(fn ($record) => $record->created_at_by_timezone) + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table + ->actions([ + Action::make('files') + ->hiddenLabel() + ->icon('heroicon-o-rectangle-stack') + ->modalHeading('Backup Files') + ->color('secondary') + ->tooltip('Show backup files') + ->authorize(fn (Backup $record) => auth()->user()->can('viewAny', [BackupFile::class, $record])) + ->modalContent(fn (Backup $record) => view('web.components.dynamic-widget', [ + 'widget' => BackupFilesList::class, + 'params' => [ + 'backup' => $record, + ], + ])) + ->modalWidth(MaxWidth::FiveExtraLarge) + ->slideOver() + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalFooterActions([ + Action::make('backup') + ->label('Run Backup') + ->icon('heroicon-o-play') + ->color('primary') + ->action(function (Backup $record) { + app(RunBackup::class)->run($record); + + $this->dispatch('$refresh'); + }), + ]), + Action::make('delete') + ->hiddenLabel() + ->icon('heroicon-o-trash') + ->modalHeading('Delete Database') + ->color('danger') + ->tooltip('Delete') + ->authorize(fn (Backup $record) => auth()->user()->can('delete', $record)) + ->requiresConfirmation() + ->action(function (Backup $record) { + try { + $record->delete(); + } catch (Exception $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->send(); + + throw $e; + } + + $this->dispatch('$refresh'); + }), + ]); + } +} diff --git a/app/Web/Pages/Servers/Index.php b/app/Web/Pages/Servers/Index.php index 3bc2981..dbe822b 100644 --- a/app/Web/Pages/Servers/Index.php +++ b/app/Web/Pages/Servers/Index.php @@ -2,9 +2,21 @@ namespace App\Web\Pages\Servers; +use App\Actions\Server\CreateServer as CreateServerAction; +use App\Enums\ServerProvider; +use App\Enums\ServerType; use App\Models\Server; use App\Web\Components\Page; -use Filament\Actions\Action; +use App\Web\Fields\AlertField; +use App\Web\Fields\ProviderField; +use App\Web\Pages\Settings\ServerProviders\Actions\Create; +use Filament\Forms\Components\Actions\Action; +use Filament\Forms\Components\Grid; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; +use Filament\Support\Enums\MaxWidth; +use Throwable; class Index extends Page { @@ -35,12 +47,194 @@ public function getWidgets(): array protected function getHeaderActions(): array { + $publicKey = __('servers.create.public_key_text', [ + 'public_key' => get_public_key_content(), + ]); + return [ - Action::make('create') + \Filament\Actions\Action::make('create') ->label('Create a Server') ->icon('heroicon-o-plus') - ->url(Create::getUrl()) - ->authorize('create', Server::class), + ->authorize('create', Server::class) + ->modalWidth(MaxWidth::FiveExtraLarge) + ->slideOver() + ->form([ + ProviderField::make('provider') + ->label('Select a provider') + ->default(ServerProvider::CUSTOM) + ->live() + ->reactive() + ->afterStateUpdated(function (callable $set) { + $set('server_provider', null); + $set('region', null); + $set('plan', null); + }) + ->rules(fn ($get) => CreateServerAction::rules($get())['provider']), + AlertField::make('alert') + ->warning() + ->message(__('servers.create.public_key_warning')) + ->visible(fn ($get) => $get('provider') === ServerProvider::CUSTOM), + Select::make('server_provider') + ->visible(fn ($get) => $get('provider') !== ServerProvider::CUSTOM) + ->label('Server provider connection') + ->rules(fn ($get) => CreateServerAction::rules($get())['server_provider']) + ->options(function ($get) { + return \App\Models\ServerProvider::getByProjectId(auth()->user()->current_project_id) + ->where('provider', $get('provider')) + ->pluck('profile', 'id'); + }) + ->suffixAction( + Action::make('connect') + ->form(Create::form()) + ->modalHeading('Connect to a new server provider') + ->modalSubmitActionLabel('Connect') + ->icon('heroicon-o-wifi') + ->tooltip('Connect to a new server provider') + ->modalWidth(MaxWidth::Medium) + ->authorize(fn () => auth()->user()->can('create', \App\Models\ServerProvider::class)) + ->action(fn (array $data) => Create::action($data)) + ) + ->placeholder('Select profile') + ->native(false) + ->selectablePlaceholder(false) + ->visible(fn ($get) => $get('provider') !== ServerProvider::CUSTOM), + Grid::make() + ->schema([ + Select::make('region') + ->label('Region') + ->rules(fn ($get) => CreateServerAction::rules($get())['region']) + ->live() + ->reactive() + ->options(function ($get) { + if (! $get('server_provider')) { + return []; + } + + return \App\Models\ServerProvider::regions($get('serer_provider')); + }) + ->loadingMessage('Loading regions...') + ->disabled(fn ($get) => ! $get('server_provider')) + ->placeholder(fn ($get) => $get('server_provider') ? 'Select region' : 'Select connection first') + ->searchable(), + Select::make('plan') + ->label('Plan') + ->rules(fn ($get) => CreateServerAction::rules($get())['plan']) + ->reactive() + ->options(function ($get) { + if (! $get('server_provider') || ! $get('region')) { + return []; + } + + return \App\Models\ServerProvider::plans($get('server_provider'), $get('region')); + }) + ->loadingMessage('Loading plans...') + ->disabled(fn ($get) => ! $get('region')) + ->placeholder(fn ($get) => $get('region') ? 'Select plan' : 'Select plan first') + ->searchable(), + ]) + ->visible(fn ($get) => $get('provider') !== ServerProvider::CUSTOM), + TextInput::make('public_key') + ->label('Public Key') + ->default($publicKey) + ->suffixAction( + Action::make('copy') + ->icon('heroicon-o-clipboard-document-list') + ->tooltip('Copy') + ->action(function ($livewire, $state) { + $livewire->js( + 'window.navigator.clipboard.writeText("'.$state.'");' + ); + Notification::make() + ->success() + ->title('Copied!') + ->send(); + }) + ) + ->helperText('Run this command on your server as root user') + ->disabled() + ->visible(fn ($get) => $get('provider') === ServerProvider::CUSTOM), + TextInput::make('name') + ->label('Name') + ->rules(fn ($get) => CreateServerAction::rules($get())['name']), + Grid::make() + ->schema([ + TextInput::make('ip') + ->label('SSH IP Address') + ->rules(fn ($get) => CreateServerAction::rules($get())['ip']), + TextInput::make('port') + ->label('SSH Port') + ->rules(fn ($get) => CreateServerAction::rules($get())['port']), + ]) + ->visible(fn ($get) => $get('provider') === ServerProvider::CUSTOM), + Grid::make() + ->schema([ + Select::make('os') + ->label('OS') + ->native(false) + ->rules(fn ($get) => CreateServerAction::rules($get())['os']) + ->options( + collect(config('core.operating_systems')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ), + Select::make('type') + ->label('Server Type') + ->native(false) + ->selectablePlaceholder(false) + ->rules(fn ($get) => CreateServerAction::rules($get())['type']) + ->options( + collect(config('core.server_types')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ) + ->default(ServerType::REGULAR), + ]), + Grid::make(3) + ->schema([ + Select::make('webserver') + ->label('Webserver') + ->native(false) + ->selectablePlaceholder(false) + ->rules(fn ($get) => CreateServerAction::rules($get())['webserver'] ?? []) + ->options( + collect(config('core.webservers'))->mapWithKeys(fn ($value) => [$value => $value]) + ), + Select::make('database') + ->label('Database') + ->native(false) + ->selectablePlaceholder(false) + ->rules(fn ($get) => CreateServerAction::rules($get())['database'] ?? []) + ->options( + collect(config('core.databases_name')) + ->mapWithKeys(fn ($value, $key) => [ + $key => $value.' '.config('core.databases_version')[$key], + ]) + ), + Select::make('php') + ->label('PHP') + ->native(false) + ->selectablePlaceholder(false) + ->rules(fn ($get) => CreateServerAction::rules($get())['php'] ?? []) + ->options( + collect(config('core.php_versions')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ), + ]), + ]) + ->action(function ($input) { + $this->authorize('create', Server::class); + + $this->validate(); + + try { + $server = app(CreateServerAction::class)->create(auth()->user(), $input); + + $this->redirect(View::getUrl(['server' => $server])); + } catch (Throwable $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + } + }), ]; } } diff --git a/app/Web/Pages/Servers/Widgets/CreateServer.php b/app/Web/Pages/Servers/Widgets/CreateServer.php index 224f292..fd2d1a1 100644 --- a/app/Web/Pages/Servers/Widgets/CreateServer.php +++ b/app/Web/Pages/Servers/Widgets/CreateServer.php @@ -9,7 +9,7 @@ use App\Models\Server; use App\Web\Fields\AlertField; use App\Web\Fields\ProviderField; -use App\Web\Pages\Servers\Index; +use App\Web\Pages\Servers\View; use App\Web\Pages\Settings\ServerProviders\Actions\Create; use Filament\Forms\Components\Actions; use Filament\Forms\Components\Actions\Action; @@ -73,7 +73,6 @@ public function form(Form $form): Form ->default(ServerProvider::CUSTOM) ->live() ->reactive() - ->reactive() ->afterStateUpdated(function (callable $set) { $set('server_provider', null); $set('region', null); @@ -184,21 +183,20 @@ public function form(Form $form): Form Select::make('os') ->label('OS') ->native(false) - ->selectablePlaceholder(false) ->rules(fn ($get) => CreateServerAction::rules($this->all())['os']) - ->options(function () { - return collect(config('core.operating_systems')) - ->mapWithKeys(fn ($value) => [$value => $value]); - }), + ->options( + collect(config('core.operating_systems')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ), Select::make('type') ->label('Server Type') ->native(false) ->selectablePlaceholder(false) ->rules(fn ($get) => CreateServerAction::rules($this->all())['type']) - ->options(function () { - return collect(config('core.server_types')) - ->mapWithKeys(fn ($value) => [$value => $value]); - }) + ->options( + collect(config('core.server_types')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ) ->default(ServerType::REGULAR), ]), Grid::make(3) @@ -208,30 +206,29 @@ public function form(Form $form): Form ->native(false) ->selectablePlaceholder(false) ->rules(fn ($get) => CreateServerAction::rules($this->all())['webserver'] ?? []) - ->options(function () { - return collect(config('core.webservers')) - ->mapWithKeys(fn ($value) => [$value => $value]); - }), + ->options( + collect(config('core.webservers'))->mapWithKeys(fn ($value) => [$value => $value]) + ), Select::make('database') ->label('Database') ->native(false) ->selectablePlaceholder(false) ->rules(fn ($get) => CreateServerAction::rules($this->all())['database'] ?? []) - ->options(function () { - return collect(config('core.databases_name')) + ->options( + collect(config('core.databases_name')) ->mapWithKeys(fn ($value, $key) => [ $key => $value.' '.config('core.databases_version')[$key], - ]); - }), + ]) + ), Select::make('php') ->label('PHP') ->native(false) ->selectablePlaceholder(false) ->rules(fn ($get) => CreateServerAction::rules($this->all())['php'] ?? []) - ->options(function () { - return collect(config('core.php_versions')) - ->mapWithKeys(fn ($value) => [$value => $value]); - }), + ->options( + collect(config('core.php_versions')) + ->mapWithKeys(fn ($value) => [$value => $value]) + ), ]), Actions::make([ Action::make('create') @@ -252,7 +249,7 @@ public function submit(): void try { $server = app(CreateServerAction::class)->create(auth()->user(), $this->all()['data']); - $this->redirect(Index::getUrl()); + $this->redirect(View::getUrl(['server' => $server])); } catch (Throwable $e) { Notification::make() ->title($e->getMessage()) diff --git a/app/Web/Pages/Servers/Widgets/ServerDetails.php b/app/Web/Pages/Servers/Widgets/ServerDetails.php index ec36eec..b782a75 100644 --- a/app/Web/Pages/Servers/Widgets/ServerDetails.php +++ b/app/Web/Pages/Servers/Widgets/ServerDetails.php @@ -54,7 +54,17 @@ public function infolist(Infolist $infolist): Infolist Action::make('check-update') ->icon('heroicon-o-arrow-path') ->tooltip('Check Now') - ->action(fn (Server $record) => $record->checkForUpdates()) + ->action(function (Server $record) { + $record->checkForUpdates(); + + $this->dispatch('$refresh'); + + Notification::make() + ->info() + ->title('Available updates:') + ->body($record->available_updates) + ->send(); + }) ), TextEntry::make('available_updates') ->label('Available Updates') diff --git a/app/Web/Pages/Settings/StorageProviders/Actions/Create.php b/app/Web/Pages/Settings/StorageProviders/Actions/Create.php new file mode 100644 index 0000000..60e348f --- /dev/null +++ b/app/Web/Pages/Settings/StorageProviders/Actions/Create.php @@ -0,0 +1,139 @@ +options( + collect(config('core.storage_providers')) + ->mapWithKeys(fn ($provider) => [$provider => $provider]) + ) + ->live() + ->reactive() + ->native(false) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['provider']), + TextInput::make('name') + ->rules(fn ($get) => CreateStorageProvider::rules($get())['name']), + TextInput::make('token') + ->label('API Token') + ->validationAttribute('API Token') + ->visible(fn ($get) => $get('provider') == StorageProvider::DROPBOX) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['token']), + Grid::make() + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->schema([ + TextInput::make('host') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['host']), + TextInput::make('port') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['port']), + TextInput::make('username') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['username']), + TextInput::make('password') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['password']), + Checkbox::make('ssl') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['ssl']), + Checkbox::make('passive') + ->visible(fn ($get) => $get('provider') == StorageProvider::FTP) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['passive']), + ]), + TextInput::make('path') + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + StorageProvider::FTP, + StorageProvider::LOCAL, + ])) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['path']) + ->helperText(function ($get) { + return match ($get('provider')) { + StorageProvider::LOCAL => 'The absolute path on your server that the database exists. like `/home/vito/db-backups`', + default => '', + }; + }), + Grid::make() + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + ])) + ->schema([ + TextInput::make('key') + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + ])) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['key']) + ->helperText(function ($get) { + return match ($get('provider')) { + StorageProvider::S3 => new Link( + href: 'https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html', + text: 'How to generate?', + external: true + ), + StorageProvider::WASABI => new Link( + href: 'https://docs.wasabi.com/docs/creating-a-user-account-and-access-key', + text: 'How to generate?', + external: true + ), + default => '', + }; + }), + TextInput::make('secret') + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + ])) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['secret']), + TextInput::make('region') + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + ])) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['region']), + TextInput::make('bucket') + ->visible(fn ($get) => in_array($get('provider'), [ + StorageProvider::S3, + StorageProvider::WASABI, + ])) + ->rules(fn ($get) => CreateStorageProvider::rules($get())['bucket']), + ]), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]; + } + + /** + * @throws Exception + */ + public static function action(array $data): void + { + try { + app(CreateStorageProvider::class)->create(auth()->user(), $data); + } catch (Exception $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + + throw $e; + } + } +} diff --git a/app/Web/Pages/Settings/StorageProviders/Actions/Edit.php b/app/Web/Pages/Settings/StorageProviders/Actions/Edit.php new file mode 100644 index 0000000..60047e6 --- /dev/null +++ b/app/Web/Pages/Settings/StorageProviders/Actions/Edit.php @@ -0,0 +1,27 @@ +label('Name') + ->rules(EditStorageProvider::rules()['name']), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]; + } + + public static function action(StorageProvider $provider, array $data): void + { + app(EditStorageProvider::class)->edit($provider, auth()->user(), $data); + } +} diff --git a/app/Web/Pages/Settings/StorageProviders/Index.php b/app/Web/Pages/Settings/StorageProviders/Index.php new file mode 100644 index 0000000..85d94f2 --- /dev/null +++ b/app/Web/Pages/Settings/StorageProviders/Index.php @@ -0,0 +1,49 @@ +user()?->can('viewAny', StorageProvider::class) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\StorageProvidersList::class], + ]; + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make() + ->label('Connect') + ->icon('heroicon-o-wifi') + ->modalHeading('Connect to a Storage Provider') + ->modalSubmitActionLabel('Connect') + ->createAnother(false) + ->form(Actions\Create::form()) + ->authorize('create', StorageProvider::class) + ->modalWidth(MaxWidth::ExtraLarge) + ->using(fn (array $data) => Actions\Create::action($data)), + ]; + } +} diff --git a/app/Web/Pages/Settings/StorageProviders/Widgets/StorageProvidersList.php b/app/Web/Pages/Settings/StorageProviders/Widgets/StorageProvidersList.php new file mode 100644 index 0000000..a224ad6 --- /dev/null +++ b/app/Web/Pages/Settings/StorageProviders/Widgets/StorageProvidersList.php @@ -0,0 +1,77 @@ +user()->current_project_id); + } + + protected static ?string $heading = ''; + + protected function getTableColumns(): array + { + return [ + ImageColumn::make('image_url') + ->label('Provider') + ->size(24), + TextColumn::make('name') + ->default(fn ($record) => $record->profile) + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('id') + ->label('Global') + ->badge() + ->color(fn ($record) => $record->project_id ? 'gray' : 'success') + ->formatStateUsing(function (StorageProvider $record) { + return $record->project_id ? 'No' : 'Yes'; + }), + TextColumn::make('created_at') + ->label('Created At') + ->formatStateUsing(fn ($record) => $record->created_at_by_timezone) + ->searchable() + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table->actions([ + EditAction::make('edit') + ->label('Edit') + ->modalHeading('Edit Storage Provider') + ->mutateRecordDataUsing(function (array $data, StorageProvider $record) { + return [ + 'name' => $record->profile, + 'global' => $record->project_id === null, + ]; + }) + ->form(Edit::form()) + ->authorize(fn (StorageProvider $record) => auth()->user()->can('update', $record)) + ->using(fn (array $data, StorageProvider $record) => Edit::action($record, $data)) + ->modalWidth(MaxWidth::Medium), + DeleteAction::make('delete') + ->label('Delete') + ->modalHeading('Delete Storage Provider') + ->authorize(fn (StorageProvider $record) => auth()->user()->can('delete', $record)) + ->using(function (array $data, StorageProvider $record) { + app(DeleteStorageProvider::class)->delete($record); + }), + ]); + } +} diff --git a/config/core.php b/config/core.php index b1da1dd..8efc339 100755 --- a/config/core.php +++ b/config/core.php @@ -481,4 +481,13 @@ \App\Enums\UserRole::USER, \App\Enums\UserRole::ADMIN, ], + + 'cronjob_intervals' => [ + '* * * * *' => 'Every Minute', + '0 * * * *' => 'Hourly', + '0 0 * * *' => 'Daily', + '0 0 * * 0' => 'Weekly', + '0 0 1 * *' => 'Monthly', + 'custom' => 'Custom', + ], ]; diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css index 7429bbf..365630e 100644 --- a/resources/css/filament/app/theme.css +++ b/resources/css/filament/app/theme.css @@ -9,3 +9,8 @@ .fi-breadcrumbs .fi-breadcrumbs-item-label { .choices__item--selectable { @apply cursor-pointer; } + +.fi-btn-color-primary { + background-image: linear-gradient(to bottom right, rgba(var(--primary-500), 1), rgba(var(--primary-900), 1)); + box-shadow: 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a; +} diff --git a/resources/views/web/components/brand.blade.php b/resources/views/web/components/brand.blade.php index c9cdaa6..b852714 100644 --- a/resources/views/web/components/brand.blade.php +++ b/resources/views/web/components/brand.blade.php @@ -1,5 +1,39 @@ -Vito + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/web/components/dynamic-widget.blade.php b/resources/views/web/components/dynamic-widget.blade.php new file mode 100644 index 0000000..2fb7c21 --- /dev/null +++ b/resources/views/web/components/dynamic-widget.blade.php @@ -0,0 +1 @@ +@livewire($widget, $params ?? [], key($widget)) diff --git a/resources/views/web/components/link.blade.php b/resources/views/web/components/link.blade.php new file mode 100644 index 0000000..f23de3d --- /dev/null +++ b/resources/views/web/components/link.blade.php @@ -0,0 +1 @@ +{{ $text }}