diff --git a/app/Actions/Site/CreateSite.php b/app/Actions/Site/CreateSite.php index 816851f..71a556c 100755 --- a/app/Actions/Site/CreateSite.php +++ b/app/Actions/Site/CreateSite.php @@ -14,7 +14,6 @@ use App\ValidationRules\DomainRule; use Exception; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -25,8 +24,6 @@ class CreateSite */ public function create(Server $server, array $input): Site { - $this->validateInputs($server, $input); - DB::beginTransaction(); try { $site = new Site([ @@ -60,9 +57,6 @@ public function create(Server $server, array $input): Site ]); } - // validate type - $this->validateType($site, $input); - // set type data $site->type_data = $site->type()->data($input); @@ -101,13 +95,9 @@ public function create(Server $server, array $input): Site /** * @throws ValidationException */ - public static function rules(array $input): void + public static function rules(Server $server, array $input): array { $rules = [ - 'server_id' => [ - 'required', - 'exists:servers,id', - ], 'type' => [ 'required', Rule::in(config('core.site_types')), @@ -124,16 +114,20 @@ public static function rules(array $input): void ], ]; - Validator::make($input, $rules)->validate(); + return array_merge($rules, self::typeRules($server, $input)); } - /** - * @throws ValidationException - */ - private function validateType(Site $site, array $input): void + private static function typeRules(Server $server, array $input): array { - $rules = $site->type()->createRules($input); + if (! isset($input['type']) || ! in_array($input['type'], config('core.site_types'))) { + return []; + } - Validator::make($input, $rules)->validate(); + $site = new Site([ + 'server_id' => $server->id, + 'type' => $input['type']] + ); + + return $site->type()->createRules($input); } } diff --git a/app/Actions/Site/DeleteSite.php b/app/Actions/Site/DeleteSite.php index fa23947..2258e40 100644 --- a/app/Actions/Site/DeleteSite.php +++ b/app/Actions/Site/DeleteSite.php @@ -3,12 +3,15 @@ namespace App\Actions\Site; use App\Models\Site; +use App\SSH\Services\Webserver\Webserver; class DeleteSite { public function delete(Site $site): void { - $site->server->webserver()->handler()->deleteSite($site); + /** @var Webserver $webserverHandler */ + $webserverHandler = $site->server->webserver()->handler(); + $webserverHandler->deleteSite($site); $site->delete(); } } diff --git a/app/Actions/Site/UpdateBranch.php b/app/Actions/Site/UpdateBranch.php index 9996cd3..bcf8cf8 100755 --- a/app/Actions/Site/UpdateBranch.php +++ b/app/Actions/Site/UpdateBranch.php @@ -4,7 +4,6 @@ use App\Models\Site; use App\SSH\Git\Git; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class UpdateBranch @@ -14,7 +13,6 @@ class UpdateBranch */ public function update(Site $site, array $input): void { - $this->validate($input); $site->branch = $input['branch']; app(Git::class)->checkout($site); $site->save(); @@ -23,10 +21,10 @@ public function update(Site $site, array $input): void /** * @throws ValidationException */ - protected function validate(array $input): void + public static function rules(): array { - Validator::make($input, [ + return [ 'branch' => 'required', - ]); + ]; } } diff --git a/app/Actions/Site/UpdateDeploymentScript.php b/app/Actions/Site/UpdateDeploymentScript.php index 4410353..47de15b 100755 --- a/app/Actions/Site/UpdateDeploymentScript.php +++ b/app/Actions/Site/UpdateDeploymentScript.php @@ -3,7 +3,6 @@ namespace App\Actions\Site; use App\Models\Site; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\ValidationException; class UpdateDeploymentScript @@ -13,8 +12,6 @@ class UpdateDeploymentScript */ public function update(Site $site, array $input): void { - $this->validate($input); - $site->deploymentScript()->update([ 'content' => $input['script'], ]); @@ -23,10 +20,10 @@ public function update(Site $site, array $input): void /** * @throws ValidationException */ - protected function validate(array $input): void + public static function rules(): array { - Validator::make($input, [ - 'script' => 'required', - ]); + return [ + 'script' => ['required', 'string'], + ]; } } diff --git a/app/Actions/SourceControl/ConnectSourceControl.php b/app/Actions/SourceControl/ConnectSourceControl.php index 667559e..bfcb8a2 100644 --- a/app/Actions/SourceControl/ConnectSourceControl.php +++ b/app/Actions/SourceControl/ConnectSourceControl.php @@ -5,7 +5,6 @@ use App\Models\SourceControl; use App\Models\User; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; @@ -13,8 +12,6 @@ class ConnectSourceControl { public function connect(User $user, array $input): void { - $this->validate($input); - $sourceControl = new SourceControl([ 'provider' => $input['provider'], 'profile' => $input['name'], @@ -22,8 +19,6 @@ public function connect(User $user, array $input): void 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, ]); - $this->validateProvider($sourceControl, $input); - $sourceControl->provider_data = $sourceControl->provider()->createData($input); if (! $sourceControl->provider()->connect()) { @@ -36,28 +31,34 @@ public function connect(User $user, array $input): void $sourceControl->save(); } - /** - * @throws ValidationException - */ - private function validate(array $input): void + public static function rules(array $input): array { $rules = [ + 'name' => [ + 'required', + ], 'provider' => [ 'required', Rule::in(config('core.source_control_providers')), ], - 'name' => [ - 'required', - ], ]; - Validator::make($input, $rules)->validate(); + + return array_merge($rules, static::providerRules($input)); } /** * @throws ValidationException */ - private function validateProvider(SourceControl $sourceControl, array $input): void + private static function providerRules(array $input): array { - Validator::make($input, $sourceControl->provider()->createRules($input))->validate(); + if (! isset($input['provider'])) { + return []; + } + + $sourceControl = new SourceControl([ + 'provider' => $input['provider'], + ]); + + return $sourceControl->provider()->createRules($input); } } diff --git a/app/Actions/SourceControl/EditSourceControl.php b/app/Actions/SourceControl/EditSourceControl.php index 54ae1d1..b5eea45 100644 --- a/app/Actions/SourceControl/EditSourceControl.php +++ b/app/Actions/SourceControl/EditSourceControl.php @@ -4,21 +4,17 @@ use App\Models\SourceControl; use App\Models\User; -use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; class EditSourceControl { public function edit(SourceControl $sourceControl, User $user, array $input): void { - $this->validate($input); - $sourceControl->profile = $input['name']; $sourceControl->url = $input['url'] ?? null; $sourceControl->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id; - $this->validateProvider($sourceControl, $input); - $sourceControl->provider_data = $sourceControl->provider()->createData($input); if (! $sourceControl->provider()->connect()) { @@ -31,24 +27,34 @@ public function edit(SourceControl $sourceControl, User $user, array $input): vo $sourceControl->save(); } - /** - * @throws ValidationException - */ - private function validate(array $input): void + public static function rules(array $input): array { $rules = [ 'name' => [ 'required', ], + 'provider' => [ + 'required', + Rule::in(config('core.source_control_providers')), + ], ]; - Validator::make($input, $rules)->validate(); + + return array_merge($rules, static::providerRules($input)); } /** * @throws ValidationException */ - private function validateProvider(SourceControl $sourceControl, array $input): void + private static function providerRules(array $input): array { - Validator::make($input, $sourceControl->provider()->createRules($input))->validate(); + if (! isset($input['provider'])) { + return []; + } + + $sourceControl = new SourceControl([ + 'provider' => $input['provider'], + ]); + + return $sourceControl->provider()->createRules($input); } } diff --git a/app/Enums/SiteType.php b/app/Enums/SiteType.php index 21d4a36..e9967d6 100644 --- a/app/Enums/SiteType.php +++ b/app/Enums/SiteType.php @@ -13,4 +13,21 @@ final class SiteType const WORDPRESS = 'wordpress'; const PHPMYADMIN = 'phpmyadmin'; + + public static function hasWebDirectory(): array + { + return [ + self::PHP, + self::PHP_BLANK, + self::LARAVEL, + ]; + } + + public static function hasSourceControl(): array + { + return [ + self::PHP, + self::LARAVEL, + ]; + } } diff --git a/app/Exceptions/SSHError.php b/app/Exceptions/SSHError.php index fc22d39..c503a9b 100755 --- a/app/Exceptions/SSHError.php +++ b/app/Exceptions/SSHError.php @@ -2,9 +2,23 @@ namespace App\Exceptions; +use App\Models\ServerLog; use Exception; +use Throwable; class SSHError extends Exception { - // + protected ?ServerLog $log; + + public function __construct(string $message = '', int $code = 0, ?Throwable $previous = null, ?ServerLog $log = null) + { + $this->log = $log; + + parent::__construct($message, $code, $previous); + } + + public function getLog(): ?ServerLog + { + return $this->log; + } } diff --git a/app/Helpers/SSH.php b/app/Helpers/SSH.php index 067c5dd..4b872f7 100755 --- a/app/Helpers/SSH.php +++ b/app/Helpers/SSH.php @@ -93,6 +93,7 @@ public function connect(bool $sftp = false): void */ public function exec(string $command, string $log = '', ?int $siteId = null, ?bool $stream = false, ?callable $streamCallback = null): string { + ds($command); if (! $this->log && $log) { $this->log = ServerLog::make($this->server, $log); if ($siteId) { @@ -129,13 +130,19 @@ public function exec(string $command, string $log = '', ?int $siteId = null, ?bo $this->log?->write($output); if ($this->connection->getExitStatus() !== 0 || Str::contains($output, 'VITO_SSH_ERROR')) { - throw new SSHCommandError('SSH command failed with an error', $this->connection->getExitStatus()); + throw new SSHCommandError( + message: 'SSH command failed with an error', + log: $this->log + ); } return $output; } } catch (Throwable $e) { - throw new SSHCommandError($e->getMessage()); + throw new SSHCommandError( + message: $e->getMessage(), + log: $this->log + ); } } diff --git a/app/Http/Middleware/HandleSSHErrors.php b/app/Http/Middleware/HandleSSHErrors.php deleted file mode 100644 index 7d2b99d..0000000 --- a/app/Http/Middleware/HandleSSHErrors.php +++ /dev/null @@ -1,31 +0,0 @@ -exception) { - // if ($res->exception instanceof SSHConnectionError || $res->exception instanceof SSHCommandError) { - // Toast::error($res->exception->getMessage()); - - // if ($request->hasHeader('HX-Request')) { - // return htmx()->back(); - // } - - // return back(); - // } - // } - - return $res; - } -} diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 7972c17..529f394 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -70,7 +70,7 @@ public function storage(): BelongsTo public function database(): BelongsTo { - return $this->belongsTo(Database::class); + return $this->belongsTo(Database::class)->withTrashed(); } public function files(): HasMany diff --git a/app/Models/Database.php b/app/Models/Database.php index 121028c..72ebac1 100755 --- a/app/Models/Database.php +++ b/app/Models/Database.php @@ -3,9 +3,11 @@ namespace App\Models; use App\Enums\DatabaseStatus; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $server_id @@ -13,10 +15,12 @@ * @property string $status * @property Server $server * @property Backup[] $backups + * @property Carbon $deleted_at */ class Database extends AbstractModel { use HasFactory; + use SoftDeletes; protected $fillable = [ 'server_id', @@ -41,9 +45,6 @@ public static function boot(): void $user->save(); } }); - $database->backups()->each(function (Backup $backup) { - $backup->delete(); - }); }); } diff --git a/app/Models/ServerLog.php b/app/Models/ServerLog.php index a537239..33728d3 100755 --- a/app/Models/ServerLog.php +++ b/app/Models/ServerLog.php @@ -97,13 +97,17 @@ public function write($buf): void } } - public function getContent(): ?string + public function getContent($lines = null): ?string { if ($this->is_remote) { - return $this->server->os()->tail($this->name, 150); + return $this->server->os()->tail($this->name, $lines ?? 150); } if (Storage::disk($this->disk)->exists($this->name)) { + if ($lines) { + return tail(Storage::disk($this->disk)->path($this->name), $lines); + } + return Storage::disk($this->disk)->get($this->name); } diff --git a/app/Models/Site.php b/app/Models/Site.php index 5d501d6..af47786 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -102,6 +102,21 @@ public static function boot(): void }); } + public function isReady(): bool + { + return $this->status === SiteStatus::READY; + } + + public function isInstalling(): bool + { + return in_array($this->status, [SiteStatus::INSTALLING, SiteStatus::INSTALLATION_FAILED]); + } + + public function isInstallationFailed(): bool + { + return $this->status === SiteStatus::INSTALLATION_FAILED; + } + public function server(): BelongsTo { return $this->belongsTo(Server::class); diff --git a/app/Models/SourceControl.php b/app/Models/SourceControl.php index 617a460..f8c17c3 100755 --- a/app/Models/SourceControl.php +++ b/app/Models/SourceControl.php @@ -15,6 +15,7 @@ * @property ?string $url * @property string $access_token * @property ?int $project_id + * @property string $image_url */ class SourceControl extends AbstractModel { @@ -63,4 +64,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/ServerLogPolicy.php b/app/Policies/ServerLogPolicy.php index a80e590..befff0a 100644 --- a/app/Policies/ServerLogPolicy.php +++ b/app/Policies/ServerLogPolicy.php @@ -35,4 +35,9 @@ public function delete(User $user, ServerLog $serverLog): bool { return $user->isAdmin() || $serverLog->server->project->users->contains($user); } + + public function deleteMany(User $user, Server $server): bool + { + return $user->isAdmin() || $server->project->users->contains($user); + } } diff --git a/app/Policies/SitePolicy.php b/app/Policies/SitePolicy.php index 080798c..f5687b7 100644 --- a/app/Policies/SitePolicy.php +++ b/app/Policies/SitePolicy.php @@ -16,9 +16,10 @@ public function viewAny(User $user, Server $server): bool return ($user->isAdmin() || $server->project->users->contains($user)) && $server->isReady(); } - public function view(User $user, Site $site): bool + public function view(User $user, Site $site, Server $server): bool { return ($user->isAdmin() || $site->server->project->users->contains($user)) && + $site->server_id === $server->id && $site->server->isReady(); } @@ -27,15 +28,17 @@ public function create(User $user, Server $server): bool return ($user->isAdmin() || $server->project->users->contains($user)) && $server->isReady(); } - public function update(User $user, Site $site): bool + public function update(User $user, Site $site, Server $server): bool { return ($user->isAdmin() || $site->server->project->users->contains($user)) && + $site->server_id === $server->id && $site->server->isReady(); } - public function delete(User $user, Site $site): bool + public function delete(User $user, Site $site, Server $server): bool { return ($user->isAdmin() || $site->server->project->users->contains($user)) && + $site->server_id === $server->id && $site->server->isReady(); } } diff --git a/app/Policies/SourceControlPolicy.php b/app/Policies/SourceControlPolicy.php new file mode 100644 index 0000000..3e10188 --- /dev/null +++ b/app/Policies/SourceControlPolicy.php @@ -0,0 +1,37 @@ +isAdmin(); + } + + public function view(User $user, SourceControl $sourceControl): bool + { + return $user->isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function update(User $user, SourceControl $sourceControl): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, SourceControl $sourceControl): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Providers/WebServiceProvider.php b/app/Providers/WebServiceProvider.php index 5c6e7dd..1336d40 100644 --- a/app/Providers/WebServiceProvider.php +++ b/app/Providers/WebServiceProvider.php @@ -9,7 +9,9 @@ use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DispatchServingFilamentEvent; use Filament\Panel; +use Filament\Support\Assets\Js; use Filament\Support\Colors\Color; +use Filament\Support\Facades\FilamentAsset; use Filament\Support\Facades\FilamentColor; use Filament\Support\Facades\FilamentView; use Filament\View\PanelsRenderHook; @@ -19,6 +21,7 @@ use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\StartSession; +use Illuminate\Support\Facades\Vite; use Illuminate\Support\ServiceProvider; use Illuminate\View\Middleware\ShareErrorsFromSession; use Livewire\Livewire; @@ -43,9 +46,12 @@ public function boot(): void PanelsRenderHook::SIDEBAR_FOOTER, fn () => view('web.components.app-version') ); + FilamentAsset::register([ + Js::make('app', Vite::asset('resources/js/app.js'))->module(), + ]); FilamentColor::register([ 'slate' => Color::Slate, - 'gray' => Color::Gray, + 'gray' => Color::Zinc, 'red' => Color::Red, 'orange' => Color::Orange, 'amber' => Color::Amber, @@ -97,7 +103,6 @@ public function panel(Panel $panel): Panel ->authMiddleware([ Authenticate::class, ]) - ->spa() ->login() ->globalSearchKeyBindings(['command+k', 'ctrl+k']) ->sidebarCollapsibleOnDesktop() diff --git a/app/SiteTypes/PHPSite.php b/app/SiteTypes/PHPSite.php index 2ee9e2a..7eb46c5 100755 --- a/app/SiteTypes/PHPSite.php +++ b/app/SiteTypes/PHPSite.php @@ -6,6 +6,7 @@ use App\Exceptions\SourceControlIsNotConnected; use App\SSH\Composer\Composer; use App\SSH\Git\Git; +use App\SSH\Services\Webserver\Webserver; use Illuminate\Validation\Rule; class PHPSite extends AbstractSiteType @@ -36,12 +37,18 @@ public function createRules(array $input): array 'required', Rule::exists('source_controls', 'id'), ], + 'web_directory' => [ + 'nullable', + ], 'repository' => [ 'required', ], 'branch' => [ 'required', ], + 'composer' => [ + 'nullable', + ], ]; } @@ -53,6 +60,7 @@ public function createFields(array $input): array 'repository' => $input['repository'] ?? '', 'branch' => $input['branch'] ?? '', 'php_version' => $input['php_version'] ?? '', + 'composer' => $input['php_version'] ?? '', ]; } diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 8054ca0..458d042 100755 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -1,6 +1,8 @@ replace("\n", '') ->toString(); } + +function run_action(object $static, Closure $callback): void +{ + try { + $callback(); + } catch (SSHError $e) { + Notification::make() + ->danger() + ->title($e->getMessage()) + ->body($e->getLog()?->getContent(10)) + ->send(); + + if (method_exists($static, 'halt')) { + $reflectionMethod = new ReflectionMethod($static, 'halt'); + $reflectionMethod->invoke($static); + } + } +} + +/** + * Credit: https://gist.github.com/lorenzos/1711e81a9162320fde20 + */ +function tail($filepath, $lines = 1, $adaptive = true): string +{ + // Open file + $f = @fopen($filepath, 'rb'); + if ($f === false) { + return ''; + } + + // Sets buffer size, according to the number of lines to retrieve. + // This gives a performance boost when reading a few lines from the file. + if (! $adaptive) { + $buffer = 4096; + } else { + $buffer = ($lines < 2 ? 64 : ($lines < 10 ? 512 : 4096)); + } + + // Jump to last character + fseek($f, -1, SEEK_END); + + // Read it and adjust line number if necessary + // (Otherwise the result would be wrong if file doesn't end with a blank line) + if (fread($f, 1) != "\n") { + $lines -= 1; + } + + // Start reading + $output = ''; + $chunk = ''; + + // While we would like more + while (ftell($f) > 0 && $lines >= 0) { + // Figure out how far back we should jump + $seek = min(ftell($f), $buffer); + + // Do the jump (backwards, relative to where we are) + fseek($f, -$seek, SEEK_CUR); + + // Read a chunk and prepend it to our output + $output = ($chunk = fread($f, $seek)).$output; + + // Jump back to where we started reading + fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR); + + // Decrease our line counter + $lines -= substr_count($chunk, "\n"); + } + + // While we have too many lines + // (Because of buffer size we might have read too many) + while ($lines++ < 0) { + // Find first newline and remove all text before that + $output = substr($output, strpos($output, "\n") + 1); + } + + // Close file and return + fclose($f); + + return trim($output); +} diff --git a/app/Web/Components/Page.php b/app/Web/Components/Page.php index 5dba3d7..b327843 100644 --- a/app/Web/Components/Page.php +++ b/app/Web/Components/Page.php @@ -2,12 +2,40 @@ namespace App\Web\Components; -use App\Web\Traits\HasWidgets; use Filament\Pages\Page as BasePage; +use Illuminate\View\ComponentAttributeBag; abstract class Page extends BasePage { - use HasWidgets; - protected static string $view = 'web.components.page'; + + protected ?string $live = '5s'; + + protected array $extraAttributes = []; + + protected function getExtraAttributes(): array + { + $attributes = $this->extraAttributes; + + if ($this->getLive()) { + $attributes['wire:poll.'.$this->getLive()] = '$dispatch(\'$refresh\')'; + } + + return $attributes; + } + + public function getExtraAttributesBag(): ComponentAttributeBag + { + return new ComponentAttributeBag($this->getExtraAttributes()); + } + + public function getLive(): ?string + { + return $this->live; + } + + public function getWidgets(): array + { + return []; + } } diff --git a/app/Web/Fields/CodeEditorField.php b/app/Web/Fields/CodeEditorField.php new file mode 100644 index 0000000..d1b0c7d --- /dev/null +++ b/app/Web/Fields/CodeEditorField.php @@ -0,0 +1,24 @@ + $this->getId(), + 'name' => $this->getName(), + 'lang' => $this->lang, + 'value' => json_encode($this->getState() ?? ''), + ]; + } +} diff --git a/app/Web/Pages/Servers/Console/Index.php b/app/Web/Pages/Servers/Console/Index.php index b1bb5e2..625c89f 100644 --- a/app/Web/Pages/Servers/Console/Index.php +++ b/app/Web/Pages/Servers/Console/Index.php @@ -2,26 +2,18 @@ namespace App\Web\Pages\Servers\Console; -use App\Models\Server; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; class Index extends Page { - use PageHasServer; - protected ?string $live = ''; protected $listeners = []; protected static ?string $slug = 'servers/{server}/console'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Console'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('update', static::getServerFromRoute()) ?? false; diff --git a/app/Web/Pages/Servers/Console/Widgets/Console.php b/app/Web/Pages/Servers/Console/Widgets/Console.php index f9b1fc5..cc35efc 100644 --- a/app/Web/Pages/Servers/Console/Widgets/Console.php +++ b/app/Web/Pages/Servers/Console/Widgets/Console.php @@ -61,15 +61,13 @@ public function form(Form $form): Form $this->running = true; $ssh = $this->server->ssh($this->data['user']); $log = 'console-'.time(); - defer(function () use ($ssh, $log) { - $ssh->exec(command: $this->data['command'], log: $log, stream: true, streamCallback: function ($output) { - $this->output .= $output; - $this->stream( - to: 'output', - content: $output, - ); - }); - })->name($log); + $ssh->exec(command: $this->data['command'], log: $log, stream: true, streamCallback: function ($output) { + $this->output .= $output; + $this->stream( + to: 'output', + content: $output, + ); + }); }), Action::make('stop') ->view('web.components.dynamic-widget', [ diff --git a/app/Web/Pages/Servers/CronJobs/Index.php b/app/Web/Pages/Servers/CronJobs/Index.php index bd23c02..15e1fee 100644 --- a/app/Web/Pages/Servers/CronJobs/Index.php +++ b/app/Web/Pages/Servers/CronJobs/Index.php @@ -4,9 +4,7 @@ use App\Actions\CronJob\CreateCronJob; use App\Models\CronJob; -use App\Models\Server; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Select; @@ -16,18 +14,12 @@ class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/cronjobs'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Cron Jobs'; protected $listeners = ['$refresh']; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [CronJob::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Databases/Backups.php b/app/Web/Pages/Servers/Databases/Backups.php index d938ac8..5af3c81 100644 --- a/app/Web/Pages/Servers/Databases/Backups.php +++ b/app/Web/Pages/Servers/Databases/Backups.php @@ -4,12 +4,10 @@ use App\Actions\Database\CreateBackup; use App\Models\Backup; -use App\Models\Server; use App\Models\StorageProvider; -use App\Web\Components\Page; use App\Web\Contracts\HasSecondSubNav; +use App\Web\Pages\Servers\Page; use App\Web\Pages\Settings\StorageProviders\Actions\Create; -use App\Web\Traits\PageHasServer; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Select; @@ -19,17 +17,12 @@ class Backups extends Page implements HasSecondSubNav { - use PageHasServer; use Traits\Navigation; protected static ?string $slug = 'servers/{server}/databases/backups'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Backups'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Backup::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Databases/Index.php b/app/Web/Pages/Servers/Databases/Index.php index 28b6638..e48cc67 100644 --- a/app/Web/Pages/Servers/Databases/Index.php +++ b/app/Web/Pages/Servers/Databases/Index.php @@ -4,11 +4,8 @@ use App\Actions\Database\CreateDatabase; 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 App\Web\Pages\Servers\Page; use Filament\Actions\Action; use Filament\Forms\Components\Checkbox; use Filament\Forms\Components\TextInput; @@ -17,17 +14,12 @@ class Index extends Page implements HasSecondSubNav { - use PageHasServer; use Traits\Navigation; protected static ?string $slug = 'servers/{server}/databases'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Databases'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Database::class, static::getServerFromRoute()]) ?? false; @@ -67,7 +59,7 @@ protected function getHeaderActions(): array ]) ->modalSubmitActionLabel('Create') ->action(function (array $data) { - try { + run_action($this, function () use ($data) { app(CreateDatabase::class)->create($this->server, $data); $this->dispatch('$refresh'); @@ -76,14 +68,7 @@ protected function getHeaderActions(): array ->success() ->title('Database Created!') ->send(); - } catch (Exception $e) { - Notification::make() - ->danger() - ->title($e->getMessage()) - ->send(); - - throw $e; - } + }); }), ]; } diff --git a/app/Web/Pages/Servers/Databases/Users.php b/app/Web/Pages/Servers/Databases/Users.php index 5712d86..6fe2ccd 100644 --- a/app/Web/Pages/Servers/Databases/Users.php +++ b/app/Web/Pages/Servers/Databases/Users.php @@ -5,10 +5,8 @@ use App\Actions\Database\CreateDatabase; use App\Actions\Database\CreateDatabaseUser; use App\Models\DatabaseUser; -use App\Models\Server; -use App\Web\Components\Page; use App\Web\Contracts\HasSecondSubNav; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Checkbox; @@ -18,17 +16,12 @@ class Users extends Page implements HasSecondSubNav { - use PageHasServer; use Traits\Navigation; protected static ?string $slug = 'servers/{server}/databases/users'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Database Users'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [DatabaseUser::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php index 286a297..40d860f 100644 --- a/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php +++ b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php @@ -5,7 +5,7 @@ use App\Actions\Database\RestoreBackup; use App\Models\Backup; use App\Models\BackupFile; -use Exception; +use App\Models\Database; use Filament\Forms\Components\Select; use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; @@ -63,7 +63,7 @@ public function getTable(): Table ->icon('heroicon-o-arrow-path') ->modalHeading('Restore Backup') ->tooltip('Restore Backup') - ->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup->database)) + ->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup)) ->form([ Select::make('database') ->label('Restore to') @@ -73,23 +73,23 @@ public function getTable(): Table ]) ->modalWidth(MaxWidth::Large) ->action(function (BackupFile $record, array $data) { - try { + run_action($this, function () use ($record, $data) { + $this->validate(); + + /** @var Database $database */ + $database = Database::query()->findOrFail($data['database']); + + $this->authorize('update', $database); + 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'); + $this->dispatch('$refresh'); + }); }), Action::make('delete') ->hiddenLabel() @@ -100,18 +100,10 @@ public function getTable(): Table ->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record)) ->requiresConfirmation() ->action(function (BackupFile $record) { - try { + run_action($this, function () use ($record) { $record->delete(); - } catch (Exception $e) { - Notification::make() - ->danger() - ->title($e->getMessage()) - ->send(); - - throw $e; - } - - $this->dispatch('$refresh'); + $this->dispatch('$refresh'); + }); }), ]); } diff --git a/app/Web/Pages/Servers/Firewall/Index.php b/app/Web/Pages/Servers/Firewall/Index.php index ea0d19a..a4aec4e 100644 --- a/app/Web/Pages/Servers/Firewall/Index.php +++ b/app/Web/Pages/Servers/Firewall/Index.php @@ -4,9 +4,7 @@ use App\Actions\FirewallRule\CreateRule; use App\Models\FirewallRule; -use App\Models\Server; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Select; @@ -16,18 +14,12 @@ class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/firewall'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Firewall'; protected $listeners = ['$refresh']; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [FirewallRule::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Logs/Index.php b/app/Web/Pages/Servers/Logs/Index.php index c0b469f..0c95c8c 100644 --- a/app/Web/Pages/Servers/Logs/Index.php +++ b/app/Web/Pages/Servers/Logs/Index.php @@ -2,24 +2,16 @@ namespace App\Web\Pages\Servers\Logs; -use App\Models\Server; use App\Models\ServerLog; -use App\Web\Components\Page; use App\Web\Pages\Servers\Logs\Widgets\LogsList; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/logs'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Logs'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [ServerLog::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Logs/Widgets/LogsList.php b/app/Web/Pages/Servers/Logs/Widgets/LogsList.php index 673609b..ac52f16 100644 --- a/app/Web/Pages/Servers/Logs/Widgets/LogsList.php +++ b/app/Web/Pages/Servers/Logs/Widgets/LogsList.php @@ -4,9 +4,13 @@ use App\Models\Server; use App\Models\ServerLog; +use App\Models\Site; use Exception; use Filament\Forms\Components\DatePicker; use Filament\Tables\Actions\Action; +use Filament\Tables\Actions\BulkActionGroup; +use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Actions\DeleteBulkAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\Filter; use Filament\Tables\Table; @@ -18,13 +22,21 @@ class LogsList extends Widget { public Server $server; + public ?Site $site = null; + + public ?string $label = ''; + protected function getTableQuery(): Builder { - return ServerLog::query()->where('server_id', $this->server->id); + return ServerLog::query() + ->where('server_id', $this->server->id) + ->where(function (Builder $query) { + if ($this->site) { + $query->where('site_id', $this->site->id); + } + }); } - protected static ?string $heading = ''; - protected function getTableColumns(): array { return [ @@ -68,6 +80,7 @@ public function getTable(): Table ); }), ]) + ->heading($this->label) ->actions([ Action::make('view') ->hiddenLabel() @@ -90,6 +103,19 @@ public function getTable(): Table ->icon('heroicon-o-archive-box-arrow-down') ->authorize(fn ($record) => auth()->user()->can('view', $record)) ->action(fn (ServerLog $record) => $record->download()), - ]); + DeleteAction::make() + ->hiddenLabel() + ->tooltip('Delete') + ->icon('heroicon-o-trash') + ->color('danger') + ->authorize(fn ($record) => auth()->user()->can('delete', $record)), + ]) + ->bulkActions( + BulkActionGroup::make([ + DeleteBulkAction::make() + ->requiresConfirmation() + ->authorize(auth()->user()->can('deleteMany', [ServerLog::class, $this->server])), + ]) + ); } } diff --git a/app/Web/Pages/Servers/Metrics/Index.php b/app/Web/Pages/Servers/Metrics/Index.php index 388ca95..87b85bb 100644 --- a/app/Web/Pages/Servers/Metrics/Index.php +++ b/app/Web/Pages/Servers/Metrics/Index.php @@ -3,22 +3,14 @@ namespace App\Web\Pages\Servers\Metrics; use App\Models\Metric; -use App\Models\Server; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/metrics'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Metrics'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Metric::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/PHP/Index.php b/app/Web/Pages/Servers/PHP/Index.php index c2e2bba..e8d4481 100644 --- a/app/Web/Pages/Servers/PHP/Index.php +++ b/app/Web/Pages/Servers/PHP/Index.php @@ -3,27 +3,19 @@ namespace App\Web\Pages\Servers\PHP; use App\Actions\PHP\InstallNewPHP; -use App\Models\Server; use App\Models\Service; -use App\Web\Components\Page; +use App\Web\Pages\Servers\Page; use App\Web\Pages\Servers\PHP\Widgets\PHPList; -use App\Web\Traits\PageHasServer; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Support\Enums\IconPosition; class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/php'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'PHP'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Service::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Traits/PageHasServer.php b/app/Web/Pages/Servers/Page.php similarity index 95% rename from app/Web/Traits/PageHasServer.php rename to app/Web/Pages/Servers/Page.php index eec35c0..ef584bb 100644 --- a/app/Web/Traits/PageHasServer.php +++ b/app/Web/Pages/Servers/Page.php @@ -1,8 +1,9 @@ user()?->can('viewAnyServer', [SshKey::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Services/Index.php b/app/Web/Pages/Servers/Services/Index.php index 0e9f959..a5958fa 100644 --- a/app/Web/Pages/Servers/Services/Index.php +++ b/app/Web/Pages/Servers/Services/Index.php @@ -3,10 +3,8 @@ namespace App\Web\Pages\Servers\Services; use App\Actions\Service\Install; -use App\Models\Server; use App\Models\Service; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; +use App\Web\Pages\Servers\Page; use Exception; use Filament\Actions\Action; use Filament\Forms\Components\Select; @@ -15,16 +13,10 @@ class Index extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/services'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Services'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Service::class, static::getServerFromRoute()]) ?? false; diff --git a/app/Web/Pages/Servers/Settings.php b/app/Web/Pages/Servers/Settings.php index fc67da9..b31f74e 100644 --- a/app/Web/Pages/Servers/Settings.php +++ b/app/Web/Pages/Servers/Settings.php @@ -4,18 +4,14 @@ use App\Actions\Server\RebootServer; use App\Models\Server; -use App\Web\Components\Page; use App\Web\Pages\Servers\Widgets\ServerDetails; use App\Web\Pages\Servers\Widgets\UpdateServerInfo; -use App\Web\Traits\PageHasServer; use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; class Settings extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/settings'; protected static bool $shouldRegisterNavigation = false; @@ -24,8 +20,6 @@ class Settings extends Page protected $listeners = ['$refresh']; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('update', static::getServerFromRoute()) ?? false; diff --git a/app/Web/Pages/Servers/Sites/Index.php b/app/Web/Pages/Servers/Sites/Index.php index 7a179ca..e611667 100644 --- a/app/Web/Pages/Servers/Sites/Index.php +++ b/app/Web/Pages/Servers/Sites/Index.php @@ -2,24 +2,28 @@ namespace App\Web\Pages\Servers\Sites; -use App\Models\Server; +use App\Actions\Site\CreateSite; +use App\Enums\SiteType; use App\Models\Site; -use App\Web\Components\Page; -use App\Web\Traits\PageHasServer; -use Filament\Actions\CreateAction; +use App\Models\SourceControl; +use App\Web\Pages\Settings\SourceControls\Actions\Create; +use Filament\Actions\Action; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Get; +use Filament\Forms\Set; +use Filament\Notifications\Notification; +use Filament\Support\Enums\MaxWidth; +use Throwable; -class Index extends Page +class Index extends \App\Web\Pages\Servers\Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}/sites'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Sites'; - public Server $server; - public static function canAccess(): bool { return auth()->user()?->can('viewAny', [Site::class, static::getServerFromRoute()]) ?? false; @@ -35,11 +39,107 @@ public function getWidgets(): array protected function getHeaderActions(): array { return [ - CreateAction::make() - ->authorize(fn () => auth()->user()?->can('create', [Site::class, $this->server])) - ->createAnother(false) + Action::make('read-the-docs') + ->label('Read the Docs') + ->icon('heroicon-o-document-text') + ->color('gray') + ->url('https://vitodeploy.com/sites/create-site.html') + ->openUrlInNewTab(), + Action::make('create') ->label('Create a Site') - ->icon('heroicon-o-plus'), + ->icon('heroicon-o-plus') + ->authorize(fn () => auth()->user()?->can('create', [Site::class, $this->server])) + ->modalWidth(MaxWidth::FiveExtraLarge) + ->slideOver() + ->form([ + Select::make('type') + ->options( + collect(config('core.site_types'))->mapWithKeys(fn ($type) => [$type => $type]) + ) + ->reactive() + ->afterStateUpdated(function (?string $state, Set $set) { + if ($state === SiteType::LARAVEL) { + $set('web_directory', 'public'); + } else { + $set('web_directory', ''); + } + }) + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['type']), + TextInput::make('domain') + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['domain']), + TagsInput::make('aliases') + ->splitKeys(['Enter', 'Tab', ' ', ',']) + ->placeholder('Type and press enter to add an alias') + ->nestedRecursiveRules(CreateSite::rules($this->server, [])['aliases.*']), + Select::make('php_version') + ->label('PHP Version') + ->options(collect($this->server->installedPHPVersions())->mapWithKeys(fn ($version) => [$version => $version])) + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['php_version'])) + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['php_version']), + TextInput::make('web_directory') + ->placeholder('For / leave empty') + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['web_directory']) + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['web_directory'])) + ->helperText( + sprintf( + 'The relative path of your website from /home/%s/your-domain/', + $this->server->ssh_user + ) + ), + Select::make('source_control') + ->label('Source Control') + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['source_control']) + ->options( + SourceControl::getByProjectId(auth()->user()->current_project_id) + ->pluck('profile', 'id') + ) + ->suffixAction( + \Filament\Forms\Components\Actions\Action::make('connect') + ->form(Create::form()) + ->modalHeading('Connect to a source control') + ->modalSubmitActionLabel('Connect') + ->icon('heroicon-o-wifi') + ->tooltip('Connect to a source control') + ->modalWidth(MaxWidth::Large) + ->authorize(fn () => auth()->user()->can('create', SourceControl::class)) + ->action(fn (array $data) => Create::action($data)) + ) + ->placeholder('Select source control') + ->live() + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['source_control'])), + TextInput::make('repository') + ->placeholder('organization/repository') + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['repository']) + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['repository'])), + TextInput::make('branch') + ->placeholder('main') + ->rules(fn (Get $get) => CreateSite::rules($this->server, $get())['branch']) + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['branch'])), + Checkbox::make('composer') + ->label('Run `composer install --no-dev`') + ->default(false) + ->visible(fn (Get $get) => isset(CreateSite::rules($this->server, $get())['composer'])), + ]) + ->action(function (array $data) { + $this->authorize('create', [Site::class, $this->server]); + + $this->validate(); + + try { + $site = app(CreateSite::class)->create($this->server, $data); + + $this->redirect(\App\Web\Pages\Servers\Sites\View::getUrl([ + 'server' => $this->server, + 'site' => $site, + ])); + } catch (Throwable $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + } + }) + ->modalSubmitActionLabel('Create Site'), ]; } } diff --git a/app/Web/Pages/Servers/Sites/Page.php b/app/Web/Pages/Servers/Sites/Page.php new file mode 100644 index 0000000..541f4a5 --- /dev/null +++ b/app/Web/Pages/Servers/Sites/Page.php @@ -0,0 +1,83 @@ +icon('heroicon-o-globe-alt') + ->isActiveWhen(fn () => request()->routeIs(View::getRouteName())) + ->url(View::getUrl(parameters: [ + 'server' => $this->server, + 'site' => $this->site, + ])); + } + + if (Pages\SSL\Index::canAccess()) { + $items[] = NavigationItem::make(Pages\SSL\Index::getNavigationLabel()) + ->icon('heroicon-o-lock-closed') + ->isActiveWhen(fn () => request()->routeIs(Pages\SSL\Index::getRouteName())) + ->url(Pages\SSL\Index::getUrl(parameters: [ + 'server' => $this->server, + 'site' => $this->site, + ])); + } + + if (Pages\Queues\Index::canAccess()) { + $items[] = NavigationItem::make(Pages\Queues\Index::getNavigationLabel()) + ->icon('heroicon-o-queue-list') + ->isActiveWhen(fn () => request()->routeIs(Pages\Queues\Index::getRouteName())) + ->url(Pages\Queues\Index::getUrl(parameters: [ + 'server' => $this->server, + 'site' => $this->site, + ])); + } + + return [ + NavigationGroup::make() + ->items($items), + ]; + } + + protected function getHeaderWidgets(): array + { + return array_merge(parent::getHeaderWidgets(), [ + SiteSummary::make(['site' => $this->site]), + ]); + } + + protected static function getSiteFromRoute(): ?Site + { + $site = request()->route('site'); + + if (! $site) { + $site = Route::getRoutes()->match(Request::create(url()->previous()))->parameter('site'); + } + + if ($site instanceof Site) { + return $site; + } + + if ($site) { + return Site::query()->find($site); + } + + return null; + } +} diff --git a/app/Web/Pages/Servers/Sites/Pages/Queues/Index.php b/app/Web/Pages/Servers/Sites/Pages/Queues/Index.php new file mode 100644 index 0000000..e711b07 --- /dev/null +++ b/app/Web/Pages/Servers/Sites/Pages/Queues/Index.php @@ -0,0 +1,22 @@ +previousStatus = $this->site->status; + } + + public static function canAccess(): bool + { + return auth()->user()?->can('view', [static::getSiteFromRoute(), static::getServerFromRoute()]) ?? false; + } + + #[On('$refresh')] + public function refresh(): void + { + $currentStatus = $this->site->refresh()->status; + + if ($this->previousStatus !== $currentStatus) { + $this->redirect(static::getUrl(parameters: [ + 'server' => $this->server, + 'site' => $this->site, + ])); + } + + $this->previousStatus = $currentStatus; + } + + public function getWidgets(): array + { + $widgets = []; + + if ($this->site->isInstalling()) { + $widgets[] = [Widgets\Installing::class, ['site' => $this->site]]; + if (auth()->user()->can('viewAny', [ServerLog::class, $this->server])) { + $widgets[] = [ + LogsList::class, [ + 'server' => $this->server, + 'site' => $this->site, + 'label' => 'Logs', + ], + ]; + } + } + + return $widgets; + } + + public function getHeaderActions(): array + { + $actions = []; + $actionsGroup = []; + + if (in_array(SiteFeature::DEPLOYMENT, $this->site->type()->supportedFeatures())) { + $actions[] = $this->deployAction(); + $actionsGroup[] = $this->deploymentScriptAction(); + } + + if (in_array(SiteFeature::ENV, $this->site->type()->supportedFeatures())) { + $actionsGroup[] = $this->dotEnvAction(); + } + + $actionsGroup[] = $this->branchAction(); + + $actions[] = ActionGroup::make($actionsGroup) + ->button() + ->color('gray') + ->icon('heroicon-o-chevron-up-down') + ->iconPosition(IconPosition::After) + ->dropdownPlacement('bottom-end'); + + return $actions; + } + + public function getSecondSubNavigation(): array + { + if ($this->site->isInstalling()) { + return []; + } + + return parent::getSecondSubNavigation(); + } + + private function deployAction(): Action + { + return Action::make('deploy') + ->icon('heroicon-o-rocket-launch') + ->action(function () { + run_action($this, function () { + app(Deploy::class)->run($this->site); + + Notification::make() + ->success() + ->title('Deployment started!') + ->send(); + }); + }); + } + + private function deploymentScriptAction(): Action + { + return Action::make('deployment-script') + ->label('Deployment Script') + ->modalSubmitActionLabel('Save') + ->modalHeading('Update Deployment Script') + ->form([ + CodeEditorField::make('script') + ->default($this->site->deploymentScript?->content) + ->rules(UpdateDeploymentScript::rules()['script']), + ]) + ->action(function (array $data) { + run_action($this, function () use ($data) { + app(UpdateDeploymentScript::class)->update($this->site, $data); + + Notification::make() + ->success() + ->title('Deployment script updated!') + ->send(); + }); + }); + } + + private function dotEnvAction(): Action + { + return Action::make('dot-env') + ->label('Update .env') + ->modalSubmitActionLabel('Save') + ->modalHeading('Update .env file') + ->form([ + CodeEditorField::make('env') + ->formatStateUsing(function () { + return $this->site->getEnv(); + }) + ->rules([ + 'env' => 'required', + ]), + ]) + ->action(function (array $data) { + run_action($this, function () use ($data) { + app(UpdateEnv::class)->update($this->site, $data); + + Notification::make() + ->success() + ->title('.env updated!') + ->send(); + }); + }); + } + + private function branchAction(): Action + { + return Action::make('branch') + ->label('Branch') + ->modalSubmitActionLabel('Save') + ->modalHeading('Change branch') + ->modalWidth(MaxWidth::Medium) + ->form([ + TextInput::make('branch') + ->default($this->site->branch) + ->rules(UpdateBranch::rules()['branch']), + ]) + ->action(function (array $data) { + run_action($this, function () use ($data) { + app(UpdateBranch::class)->update($this->site, $data); + + Notification::make() + ->success() + ->title('Branch updated!') + ->send(); + }); + }); + } +} diff --git a/app/Web/Pages/Servers/Sites/Widgets/Installing.php b/app/Web/Pages/Servers/Sites/Widgets/Installing.php new file mode 100644 index 0000000..2f0d088 --- /dev/null +++ b/app/Web/Pages/Servers/Sites/Widgets/Installing.php @@ -0,0 +1,57 @@ +schema([ + Section::make() + ->heading('Installing Site') + ->icon(function () { + if ($this->site->isInstallationFailed()) { + return 'heroicon-o-x-circle'; + } + + return view('filament::components.loading-indicator') + ->with('attributes', new ComponentAttributeBag([ + 'class' => 'mr-2 size-[24px] text-primary-400', + ])); + }) + ->iconColor($this->site->isInstallationFailed() ? 'danger' : 'primary') + ->schema([ + ViewEntry::make('progress') + ->hiddenLabel() + ->view('components.progress-bar') + ->viewData([ + 'value' => $this->site->progress, + ]), + ]), + ]) + ->record($this->site->refresh()); + } +} diff --git a/app/Web/Pages/Servers/Sites/Widgets/SiteSummary.php b/app/Web/Pages/Servers/Sites/Widgets/SiteSummary.php new file mode 100644 index 0000000..389a62c --- /dev/null +++ b/app/Web/Pages/Servers/Sites/Widgets/SiteSummary.php @@ -0,0 +1,55 @@ +schema([ + Fieldset::make('info') + ->label('Site Summary') + ->schema([ + TextEntry::make('domain') + ->icon('heroicon-o-clipboard-document') + ->iconPosition(IconPosition::After) + ->copyable(), + TextEntry::make('path') + ->icon('heroicon-o-clipboard-document') + ->iconPosition(IconPosition::After) + ->copyable(), + TextEntry::make('status') + ->label('Status') + ->badge() + ->color(static function ($state): string { + return Site::$statusColors[$state]; + }), + ]) + ->columns(3), + ]) + ->record($this->site->refresh()); + } +} diff --git a/app/Web/Pages/Servers/Sites/Widgets/SitesList.php b/app/Web/Pages/Servers/Sites/Widgets/SitesList.php index abe1c40..cb24d7c 100644 --- a/app/Web/Pages/Servers/Sites/Widgets/SitesList.php +++ b/app/Web/Pages/Servers/Sites/Widgets/SitesList.php @@ -4,7 +4,7 @@ use App\Models\Server; use App\Models\Site; -use App\Web\Pages\Servers\View; +use App\Web\Pages\Servers\Sites\View; use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -28,11 +28,12 @@ protected function getTableColumns(): array TextColumn::make('id') ->searchable() ->sortable(), - TextColumn::make('server.name') + TextColumn::make('domain') ->searchable() ->sortable(), - TextColumn::make('domain') - ->searchable(), + TextColumn::make('created_at') + ->formatStateUsing(fn (Site $record) => $record->created_at_by_timezone) + ->sortable(), TextColumn::make('status') ->label('Status') ->badge() @@ -45,13 +46,13 @@ protected function getTableColumns(): array public function getTable(): Table { return $this->table -// ->recordUrl(fn (Server $record) => View::getUrl(parameters: ['server' => $record])) + ->recordUrl(fn (Site $record) => View::getUrl(parameters: ['server' => $this->server, 'site' => $record])) ->actions([ - // Action::make('settings') - // ->label('Settings') - // ->icon('heroicon-o-cog-6-tooth') - // ->authorize(fn ($record) => auth()->user()->can('update', $record)) - // ->url(fn (Server $record) => '/'), + Action::make('settings') + ->label('Settings') + ->icon('heroicon-o-cog-6-tooth') + ->authorize(fn (Site $record) => auth()->user()->can('update', [$record, $this->server])) + ->url(fn (Site $record) => '/'), ]); } } diff --git a/app/Web/Pages/Servers/View.php b/app/Web/Pages/Servers/View.php index 5371080..1735c0e 100644 --- a/app/Web/Pages/Servers/View.php +++ b/app/Web/Pages/Servers/View.php @@ -2,27 +2,18 @@ namespace App\Web\Pages\Servers; -use App\Models\Server; use App\Models\ServerLog; -use App\Web\Components\Page; use App\Web\Pages\Servers\Logs\Widgets\LogsList; use App\Web\Pages\Servers\Widgets\Installing; use App\Web\Pages\Servers\Widgets\ServerStats; -use App\Web\Traits\PageHasServer; use Livewire\Attributes\On; class View extends Page { - use PageHasServer; - protected static ?string $slug = 'servers/{server}'; - protected static bool $shouldRegisterNavigation = false; - protected static ?string $title = 'Overview'; - public Server $server; - public string $previousStatus; public function mount(): void @@ -58,7 +49,12 @@ public function getWidgets(): array } if (auth()->user()->can('viewAny', [ServerLog::class, $this->server])) { - $widgets[] = [LogsList::class, ['server' => $this->server]]; + $widgets[] = [ + LogsList::class, [ + 'server' => $this->server, + 'label' => 'Logs', + ], + ]; } return $widgets; diff --git a/app/Web/Pages/Servers/Widgets/ServerDetails.php b/app/Web/Pages/Servers/Widgets/ServerDetails.php index 6cec282..f4c1a69 100644 --- a/app/Web/Pages/Servers/Widgets/ServerDetails.php +++ b/app/Web/Pages/Servers/Widgets/ServerDetails.php @@ -73,6 +73,7 @@ public function infolist(Infolist $infolist): Infolist Action::make('update-server') ->icon('heroicon-o-check-circle') ->tooltip('Update Now') + ->requiresConfirmation() ->action(function (Server $record) { app(Update::class)->update($record); diff --git a/app/Web/Pages/Settings/Projects/Settings.php b/app/Web/Pages/Settings/Projects/Settings.php index 4e0bf0b..4a4d338 100644 --- a/app/Web/Pages/Settings/Projects/Settings.php +++ b/app/Web/Pages/Settings/Projects/Settings.php @@ -4,7 +4,7 @@ use App\Actions\Projects\DeleteProject; use App\Models\Project; -use App\Web\Components\Page; +use App\Web\Pages\Servers\Page; use App\Web\Pages\Settings\Projects\Widgets\AddUser; use App\Web\Pages\Settings\Projects\Widgets\ProjectUsersList; use App\Web\Pages\Settings\Projects\Widgets\UpdateProject; @@ -19,8 +19,6 @@ class Settings extends Page protected static ?string $title = 'Project Settings'; - protected static bool $shouldRegisterNavigation = false; - public static function canAccess(): bool { return auth()->user()?->can('update', request()->route('project')) ?? false; diff --git a/app/Web/Pages/Settings/SourceControls/Actions/Create.php b/app/Web/Pages/Settings/SourceControls/Actions/Create.php new file mode 100644 index 0000000..c33c5a9 --- /dev/null +++ b/app/Web/Pages/Settings/SourceControls/Actions/Create.php @@ -0,0 +1,69 @@ +options( + collect(config('core.source_control_providers')) + ->mapWithKeys(fn ($provider) => [$provider => $provider]) + ) + ->live() + ->reactive() + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['provider']), + TextInput::make('name') + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['name']), + TextInput::make('token') + ->label('API Key') + ->validationAttribute('API Key') + ->visible(fn ($get) => in_array($get('provider'), [ + SourceControl::GITHUB, + SourceControl::GITLAB, + ])) + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['token']), + TextInput::make('url') + ->label('URL (optional)') + ->visible(fn ($get) => $get('provider') == SourceControl::GITLAB) + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['url']) + ->helperText('If you run a self-managed gitlab enter the url here, leave empty to use gitlab.com'), + TextInput::make('username') + ->visible(fn ($get) => $get('provider') == SourceControl::BITBUCKET) + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['username']), + TextInput::make('password') + ->visible(fn ($get) => $get('provider') == SourceControl::BITBUCKET) + ->rules(fn (Get $get) => ConnectSourceControl::rules($get())['password']), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]; + } + + /** + * @throws Exception + */ + public static function action(array $data): void + { + try { + app(ConnectSourceControl::class)->connect(auth()->user(), $data); + } catch (Exception $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + + throw $e; + } + } +} diff --git a/app/Web/Pages/Settings/SourceControls/Actions/Edit.php b/app/Web/Pages/Settings/SourceControls/Actions/Edit.php new file mode 100644 index 0000000..7591b29 --- /dev/null +++ b/app/Web/Pages/Settings/SourceControls/Actions/Edit.php @@ -0,0 +1,33 @@ +edit($provider, auth()->user(), $data); + } catch (Exception $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + + throw $e; + } + } +} diff --git a/app/Web/Pages/Settings/SourceControls/Index.php b/app/Web/Pages/Settings/SourceControls/Index.php new file mode 100644 index 0000000..c0f7345 --- /dev/null +++ b/app/Web/Pages/Settings/SourceControls/Index.php @@ -0,0 +1,52 @@ +user()?->can('viewAny', SourceControl::class) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\SourceControlsList::class], + ]; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('connect') + ->label('Connect') + ->icon('heroicon-o-wifi') + ->modalHeading('Connect to a Source Control') + ->modalSubmitActionLabel('Connect') + ->form(Actions\Create::form()) + ->authorize('create', SourceControl::class) + ->modalWidth(MaxWidth::Large) + ->action(function (array $data) { + Actions\Create::action($data); + + $this->dispatch('$refresh'); + }), + ]; + } +} diff --git a/app/Web/Pages/Settings/SourceControls/Widgets/SourceControlsList.php b/app/Web/Pages/Settings/SourceControls/Widgets/SourceControlsList.php new file mode 100644 index 0000000..ac5afb4 --- /dev/null +++ b/app/Web/Pages/Settings/SourceControls/Widgets/SourceControlsList.php @@ -0,0 +1,84 @@ +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 (SourceControl $record) => $record->profile) + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('id') + ->label('Global') + ->badge() + ->color(fn (SourceControl $record) => $record->project_id ? 'gray' : 'success') + ->formatStateUsing(function (SourceControl $record) { + return $record->project_id ? 'No' : 'Yes'; + }), + TextColumn::make('created_at') + ->label('Created At') + ->formatStateUsing(fn (SourceControl $record) => $record->created_at_by_timezone) + ->searchable() + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table->actions([ + EditAction::make('edit') + ->label('Edit') + ->modalHeading('Edit Source Control') + ->mutateRecordDataUsing(function (array $data, SourceControl $record) { + return [ + 'name' => $record->profile, + 'token' => $record->provider_data['token'] ?? null, + 'username' => $record->provider_data['username'] ?? null, + 'password' => $record->provider_data['password'] ?? null, + 'global' => $record->project_id === null, + ]; + }) + ->form(Edit::form()) + ->authorize(fn (SourceControl $record) => auth()->user()->can('update', $record)) + ->using(fn (array $data, SourceControl $record) => Edit::action($record, $data)) + ->modalWidth(MaxWidth::Medium), + DeleteAction::make('delete') + ->label('Delete') + ->modalHeading('Delete Source Control') + ->authorize(fn (SourceControl $record) => auth()->user()->can('delete', $record)) + ->using(function (array $data, SourceControl $record) { + app(DeleteSourceControl::class)->delete($record); + + $this->dispatch('$refresh'); + }), + ]); + } +} diff --git a/app/Web/Traits/HasWidgets.php b/app/Web/Traits/HasWidgets.php deleted file mode 100644 index 74cb95a..0000000 --- a/app/Web/Traits/HasWidgets.php +++ /dev/null @@ -1,38 +0,0 @@ -extraAttributes; - - if ($this->getLive()) { - $attributes['wire:poll.'.$this->getLive()] = '$dispatch(\'$refresh\')'; - } - - return $attributes; - } - - public function getExtraAttributesBag(): ComponentAttributeBag - { - return new ComponentAttributeBag($this->getExtraAttributes()); - } - - public function getLive(): ?string - { - return $this->live; - } - - public function getWidgets(): array - { - return []; - } -} diff --git a/database/migrations/2024_10_04_190341_add_soft_deletes_to_databases.php b/database/migrations/2024_10_04_190341_add_soft_deletes_to_databases.php new file mode 100644 index 0000000..570dafa --- /dev/null +++ b/database/migrations/2024_10_04_190341_add_soft_deletes_to_databases.php @@ -0,0 +1,22 @@ +softDeletes(); + }); + } + + public function down(): void + { + Schema::table('databases', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/public/static/images/github.svg b/public/static/images/github.svg index 0e859cf..24ab51b 100644 --- a/public/static/images/github.svg +++ b/public/static/images/github.svg @@ -1,6 +1,9 @@ - + - + diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css index 6105df2..d7824dc 100644 --- a/resources/css/filament/app/theme.css +++ b/resources/css/filament/app/theme.css @@ -11,13 +11,18 @@ .choices__item--selectable { } .fi-sidebar { - @apply bg-gray-100/50 dark:bg-gray-900/50 !important; + @apply bg-gray-100 dark:bg-gray-900 !important; +} + +.fi-sidebar-item a, .fi-tenant-menu-trigger { + @apply hover:bg-gray-200/50 hover:dark:bg-gray-800 !important; } .fi-sidebar-item-active a { - @apply bg-gray-100 dark:bg-gray-800/50 !important; + @apply bg-gray-200/50 dark:bg-gray-800 !important; } + .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/js/ace-editor/ace-editor.js b/resources/js/ace-editor/ace-editor.js index d9676b8..5fdbad1 100644 --- a/resources/js/ace-editor/ace-editor.js +++ b/resources/js/ace-editor/ace-editor.js @@ -1,40 +1,40 @@ -import ace from 'brace'; -import 'brace/mode/javascript'; -import 'brace/mode/plain_text'; -import 'brace/mode/sh'; -import 'brace/mode/ini'; -import 'brace/ext/searchbox' -import './theme-vito' -import './mode-env'; -import './mode-nginx'; +import ace from "brace"; +import "brace/mode/javascript"; +import "brace/mode/plain_text"; +import "brace/mode/sh"; +import "brace/mode/ini"; +import "brace/ext/searchbox"; +import "./theme-vito"; +import "./mode-env"; +import "./mode-nginx"; window.initAceEditor = function (options = {}) { - const editorValue = JSON.parse(options.value || ''); + const editorValue = JSON.parse(options.value || ""); const editor = ace.edit(options.id); editor.setTheme("ace/theme/vito"); - editor.getSession().setMode(`ace/mode/${options.lang || 'plain_text'}`); editor.setValue(editorValue, -1); editor.clearSelection(); editor.focus(); editor.setOptions({ - enableBasicAutocompletion: true, - enableSnippets: true, - enableLiveAutocompletion: true, + // enableBasicAutocompletion: true, + // enableSnippets: true, + // enableLiveAutocompletion: true, printMargin: false, }); - editor.renderer.setScrollMargin(15, 15, 0, 0) + editor.renderer.setScrollMargin(15, 15, 0, 0); editor.renderer.setPadding(15); - editor.getSession().on('change', function () { - document.getElementById(`textarea-${options.id}`).value = editor.getValue(); + editor.getSession().on("change", function () { + document.getElementById(`textarea-${options.id}`).value = + editor.getValue(); }); - window.addEventListener('resize', function () { + window.addEventListener("resize", function () { editor.resize(); - }) + }); document.getElementById(`textarea-${options.id}`).innerHTML = editorValue; return editor; -} +}; diff --git a/resources/js/app.js b/resources/js/app.js index b559f93..86b4451 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,62 +1,7 @@ -import 'flowbite'; -import 'flowbite/dist/datepicker.js'; -import './ace-editor/ace-editor'; +import CodeEditorAlpinePlugin from "./components/editor"; -import Alpine from 'alpinejs'; -window.Alpine = Alpine; -Alpine.start(); - -import ApexCharts from 'apexcharts'; -window.ApexCharts = ApexCharts; - -import htmx from "htmx.org"; -window.htmx = htmx; -window.htmx.defineExtension('disable-element', { - onEvent: function (name, evt) { - let elt = evt.detail.elt; - let target = elt.getAttribute("hx-disable-element"); - let targetElements = (target === "self") ? [elt] : document.querySelectorAll(target); - - for (let i = 0; i < targetElements.length; i++) { - if (name === "htmx:beforeRequest" && targetElements[i]) { - targetElements[i].disabled = true; - } else if (name === "htmx:afterRequest" && targetElements[i]) { - targetElements[i].disabled = false; - } - } - } -}); -document.body.addEventListener('htmx:configRequest', (event) => { - event.detail.headers['X-CSRF-TOKEN'] = document.head.querySelector('meta[name="csrf-token"]').content; - // if (window.getSelection) { window.getSelection().removeAllRanges(); } - // else if (document.selection) { document.selection.empty(); } -}); -document.body.addEventListener('htmx:beforeRequest', (event) => { - let targetElements = event.target.querySelectorAll('[hx-disable]'); - for (let i = 0; i < targetElements.length; i++) { - targetElements[i].disabled = true; - } -}); -document.body.addEventListener('htmx:afterRequest', (event) => { - let targetElements = event.target.querySelectorAll('[hx-disable]'); - for (let i = 0; i < targetElements.length; i++) { - targetElements[i].disabled = false; - } -}); -document.body.addEventListener('htmx:afterSwap', (event) => { - tippy('[data-tooltip]', { - content(reference) { - return reference.getAttribute('data-tooltip'); - }, - }); -}); - -import tippy from 'tippy.js'; -import 'tippy.js/dist/tippy.css'; -tippy('[data-tooltip]', { - content(reference) { - return reference.getAttribute('data-tooltip'); - }, +document.addEventListener("alpine:init", () => { + window.Alpine.plugin(CodeEditorAlpinePlugin); }); window.copyToClipboard = async function (text) { @@ -73,11 +18,11 @@ window.copyToClipboard = async function (text) { textArea.select(); try { - document.execCommand('copy'); + document.execCommand("copy"); } catch (error) { // } finally { textArea.remove(); } } -} +}; diff --git a/resources/js/components/editor.js b/resources/js/components/editor.js new file mode 100644 index 0000000..459a910 --- /dev/null +++ b/resources/js/components/editor.js @@ -0,0 +1,48 @@ +import ace from "brace"; +import "brace/mode/ini"; +import "brace/ext/searchbox"; +import "../ace-editor/theme-vito"; +import "../ace-editor/mode-env"; +import "../ace-editor/mode-nginx"; + +export default (Alpine) => { + Alpine.data("codeEditorFormComponent", ({ state, options }) => { + return { + state, + options, + init: function () { + this.render(); + }, + render() { + this.editor = null; + + const editorValue = JSON.parse(this.options.value || ""); + this.editor = ace.edit(this.options.id); + this.editor.$blockScrolling = Infinity; + this.editor.setTheme("ace/theme/vito"); + this.editor.setValue(editorValue, -1); + this.editor + .getSession() + .setMode(`ace/mode/${this.options.lang || "plain_text"}`); + this.editor.clearSelection(); + this.editor.focus(); + this.editor.setOptions({ + printMargin: false, + }); + + this.editor.renderer.setScrollMargin(15, 15, 0, 0); + this.editor.renderer.setPadding(15); + + this.editor.getSession().on("change", () => { + this.state = this.editor.getValue(); + }); + + window.addEventListener("resize", () => { + this.editor.resize(); + }); + + this.state = editorValue; + }, + }; + }); +}; diff --git a/resources/views/web/components/form.blade.php b/resources/views/web/components/form.blade.php index b7e91aa..77fe8f8 100644 --- a/resources/views/web/components/form.blade.php +++ b/resources/views/web/components/form.blade.php @@ -1,5 +1,5 @@
-
+ {{ $this->form }}
diff --git a/resources/views/web/components/page.blade.php b/resources/views/web/components/page.blade.php index 0f4703a..f0e1f93 100644 --- a/resources/views/web/components/page.blade.php +++ b/resources/views/web/components/page.blade.php @@ -1,13 +1,11 @@
getExtraAttributesBag() }}> - @if (method_exists($this, "getSecondSubNavigation")) + @if (method_exists($this, "getSecondSubNavigation") && count($this->getSecondSubNavigation()) > 0) @endif @foreach ($this->getWidgets() as $key => $widget) @livewire($widget[0], $widget[1] ?? [], key(class_basename($widget[0]) . "-" . $key)) @endforeach - -
diff --git a/resources/views/web/fields/code-editor.blade.php b/resources/views/web/fields/code-editor.blade.php new file mode 100644 index 0000000..0681d22 --- /dev/null +++ b/resources/views/web/fields/code-editor.blade.php @@ -0,0 +1,18 @@ + +
+
+
merge(["class" => "mt-1 min-h-[400px] w-full border border-gray-100 dark:border-gray-700"]) }} + class="ace-vito ace_dark" + >
+ +
+
+