diff --git a/app/Actions/NotificationChannels/AddChannel.php b/app/Actions/NotificationChannels/AddChannel.php index 11e98aef..5e3ef93c 100644 --- a/app/Actions/NotificationChannels/AddChannel.php +++ b/app/Actions/NotificationChannels/AddChannel.php @@ -4,7 +4,7 @@ use App\Models\NotificationChannel; use App\Models\User; -use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; class AddChannel @@ -14,14 +14,12 @@ class AddChannel */ public function add(User $user, array $input): void { - $this->validate($input); $channel = new NotificationChannel([ 'user_id' => $user->id, 'provider' => $input['provider'], 'label' => $input['label'], 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, ]); - $this->validateType($channel, $input); $channel->data = $channel->provider()->createData($input); $channel->save(); @@ -43,23 +41,29 @@ public function add(User $user, array $input): void $channel->save(); } - /** - * @throws ValidationException - */ - protected function validate(array $input): void + public static function rules(array $input): array { - Validator::make($input, [ - 'provider' => 'required|in:'.implode(',', config('core.notification_channels_providers')), + $rules = [ + 'provider' => [ + 'required', + Rule::in(config('core.notification_channels_providers')), + ], 'label' => 'required', - ])->validate(); + ]; + + return array_merge($rules, static::providerRules($input)); } - /** - * @throws ValidationException - */ - protected function validateType(NotificationChannel $channel, array $input): void + private static function providerRules(array $input): array { - Validator::make($input, $channel->provider()->createRules($input)) - ->validate(); + if (! isset($input['provider'])) { + return []; + } + + $notificationChannel = new NotificationChannel([ + 'provider' => $input['provider'], + ]); + + return $notificationChannel->provider()->createRules($input); } } diff --git a/app/Actions/NotificationChannels/EditChannel.php b/app/Actions/NotificationChannels/EditChannel.php index fbba3de7..1404930e 100644 --- a/app/Actions/NotificationChannels/EditChannel.php +++ b/app/Actions/NotificationChannels/EditChannel.php @@ -4,31 +4,22 @@ use App\Models\NotificationChannel; use App\Models\User; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\ValidationException; class EditChannel { public function edit(NotificationChannel $notificationChannel, User $user, array $input): void { - $this->validate($input); - - $notificationChannel->label = $input['label']; - $notificationChannel->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id; - + $notificationChannel->fill([ + 'label' => $input['label'], + 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, + ]); $notificationChannel->save(); } - /** - * @throws ValidationException - */ - private function validate(array $input): void + public static function rules(array $input): array { - $rules = [ - 'label' => [ - 'required', - ], + return [ + 'label' => 'required', ]; - Validator::make($input, $rules)->validate(); } } diff --git a/app/Actions/Script/CreateScript.php b/app/Actions/Script/CreateScript.php index 7e155b08..07348687 100644 --- a/app/Actions/Script/CreateScript.php +++ b/app/Actions/Script/CreateScript.php @@ -4,29 +4,27 @@ use App\Models\Script; use App\Models\User; -use Illuminate\Support\Facades\Validator; class CreateScript { public function create(User $user, array $input): Script { - $this->validate($input); - $script = new Script([ 'user_id' => $user->id, 'name' => $input['name'], 'content' => $input['content'], + 'project_id' => isset($input['global']) && $input['global'] ? null : $user->current_project_id, ]); $script->save(); return $script; } - private function validate(array $input): void + public static function rules(): array { - Validator::make($input, [ + return [ 'name' => ['required', 'string', 'max:255'], 'content' => ['required', 'string'], - ])->validate(); + ]; } } diff --git a/app/Actions/Script/EditScript.php b/app/Actions/Script/EditScript.php index b0251288..b155354f 100644 --- a/app/Actions/Script/EditScript.php +++ b/app/Actions/Script/EditScript.php @@ -3,26 +3,26 @@ namespace App\Actions\Script; use App\Models\Script; -use Illuminate\Support\Facades\Validator; +use App\Models\User; class EditScript { - public function edit(Script $script, array $input): Script + public function edit(Script $script, User $user, array $input): Script { - $this->validate($input); - $script->name = $input['name']; $script->content = $input['content']; + $script->project_id = isset($input['global']) && $input['global'] ? null : $user->current_project_id; + $script->save(); return $script; } - private function validate(array $input): void + public static function rules(): array { - Validator::make($input, [ + return [ 'name' => ['required', 'string', 'max:255'], 'content' => ['required', 'string'], - ])->validate(); + ]; } } diff --git a/app/Actions/Script/ExecuteScript.php b/app/Actions/Script/ExecuteScript.php index 5c601e12..ca2dd0a8 100644 --- a/app/Actions/Script/ExecuteScript.php +++ b/app/Actions/Script/ExecuteScript.php @@ -7,30 +7,28 @@ use App\Models\ScriptExecution; use App\Models\Server; use App\Models\ServerLog; -use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; class ExecuteScript { - public function execute(Script $script, Server $server, array $input): ScriptExecution + public function execute(Script $script, array $input): ScriptExecution { - $this->validate($server, $input); - $execution = new ScriptExecution([ 'script_id' => $script->id, + 'server_id' => $input['server'], 'user' => $input['user'], 'variables' => $input['variables'] ?? [], 'status' => ScriptExecutionStatus::EXECUTING, ]); $execution->save(); - dispatch(function () use ($execution, $server, $script) { + dispatch(function () use ($execution, $script) { $content = $execution->getContent(); - $log = ServerLog::make($server, 'script-'.$script->id.'-'.strtotime('now')); + $log = ServerLog::make($execution->server, 'script-'.$script->id.'-'.strtotime('now')); $log->save(); $execution->server_log_id = $log->id; $execution->save(); - $server->os()->runScript('~/', $content, $log, $execution->user); + $execution->server->os()->runScript('~/', $content, $log, $execution->user); $execution->status = ScriptExecutionStatus::COMPLETED; $execution->save(); })->catch(function () use ($execution) { @@ -41,14 +39,23 @@ public function execute(Script $script, Server $server, array $input): ScriptExe return $execution; } - private function validate(Server $server, array $input): void + public static function rules(array $input): array { - Validator::make($input, [ + if (isset($input['server'])) { + /** @var ?Server $server */ + $server = Server::query()->find($input['server']); + } + + return [ + 'server' => [ + 'required', + Rule::exists('servers', 'id'), + ], 'user' => [ 'required', Rule::in([ 'root', - $server->ssh_user, + isset($server) ? $server?->ssh_user : null, ]), ], 'variables' => 'array', @@ -57,6 +64,6 @@ private function validate(Server $server, array $input): void 'string', 'max:255', ], - ])->validate(); + ]; } } diff --git a/app/Models/Script.php b/app/Models/Script.php index 1f3b35ea..e5306378 100644 --- a/app/Models/Script.php +++ b/app/Models/Script.php @@ -3,6 +3,7 @@ namespace App\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,9 @@ * @property Carbon $updated_at * @property Collection<ScriptExecution> $executions * @property ?ScriptExecution $lastExecution + * @property User $user + * @property int $project_id + * @property ?Project $project */ class Script extends AbstractModel { @@ -27,6 +31,12 @@ class Script extends AbstractModel 'user_id', 'name', 'content', + 'project_id', + ]; + + protected $casts = [ + 'user_id' => 'int', + 'project_id' => 'int', ]; public static function boot(): void @@ -43,6 +53,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + public function getVariables(): array { $variables = []; @@ -63,4 +78,14 @@ public function lastExecution(): HasOne { return $this->hasOne(ScriptExecution::class)->latest(); } + + public static function getByProjectId(int $projectId, int $userId): Builder + { + return self::query() + ->where(function (Builder $query) use ($projectId, $userId) { + $query->where('project_id', $projectId) + ->orWhere('user_id', $userId) + ->orWhereNull('project_id'); + }); + } } diff --git a/app/Models/ScriptExecution.php b/app/Models/ScriptExecution.php index e7d5fdaa..6b8c1e9a 100644 --- a/app/Models/ScriptExecution.php +++ b/app/Models/ScriptExecution.php @@ -2,15 +2,16 @@ namespace App\Models; +use App\Enums\ScriptExecutionStatus; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** * @property int $id * @property int $script_id * @property int $server_log_id + * @property ?int $server_id * @property string $user * @property array $variables * @property string $status @@ -18,13 +19,15 @@ * @property Carbon $updated_at * @property Script $script * @property ?ServerLog $serverLog + * @property ?Server $server */ -class ScriptExecution extends Model +class ScriptExecution extends AbstractModel { use HasFactory; protected $fillable = [ 'script_id', + 'server_id', 'server_log_id', 'user', 'variables', @@ -33,10 +36,17 @@ class ScriptExecution extends Model protected $casts = [ 'script_id' => 'integer', + 'server_id' => 'integer', 'server_log_id' => 'integer', 'variables' => 'array', ]; + public static array $statusColors = [ + ScriptExecutionStatus::EXECUTING => 'warning', + ScriptExecutionStatus::COMPLETED => 'success', + ScriptExecutionStatus::FAILED => 'danger', + ]; + public function script(): BelongsTo { return $this->belongsTo(Script::class); @@ -58,4 +68,22 @@ public function serverLog(): BelongsTo { return $this->belongsTo(ServerLog::class); } + + public function server(): BelongsTo + { + return $this->belongsTo(Server::class); + } + + public function getServer(): ?Server + { + if ($this->server_id) { + return $this->server; + } + + if ($this->server_log_id) { + return $this->serverLog?->server; + } + + return null; + } } diff --git a/app/Models/SshKey.php b/app/Models/SshKey.php index dcb27e53..cf420844 100644 --- a/app/Models/SshKey.php +++ b/app/Models/SshKey.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $user_id @@ -16,6 +17,7 @@ class SshKey extends AbstractModel { use HasFactory; + use SoftDeletes; protected $fillable = [ 'user_id', diff --git a/app/NotificationChannels/Discord.php b/app/NotificationChannels/Discord.php index f41e2586..3807f20a 100644 --- a/app/NotificationChannels/Discord.php +++ b/app/NotificationChannels/Discord.php @@ -11,7 +11,10 @@ class Discord extends AbstractNotificationChannel public function createRules(array $input): array { return [ - 'webhook_url' => 'required|url', + 'webhook_url' => [ + 'required', + 'url', + ], ]; } diff --git a/app/NotificationChannels/Email.php b/app/NotificationChannels/Email.php index 6ff6006e..389d5052 100644 --- a/app/NotificationChannels/Email.php +++ b/app/NotificationChannels/Email.php @@ -13,7 +13,10 @@ class Email extends AbstractNotificationChannel public function createRules(array $input): array { return [ - 'email' => 'required|email', + 'email' => [ + 'required', + 'email', + ], ]; } diff --git a/app/NotificationChannels/Slack.php b/app/NotificationChannels/Slack.php index 3bc28cd3..e20fffa6 100644 --- a/app/NotificationChannels/Slack.php +++ b/app/NotificationChannels/Slack.php @@ -11,7 +11,10 @@ class Slack extends AbstractNotificationChannel public function createRules(array $input): array { return [ - 'webhook_url' => 'required|url', + 'webhook_url' => [ + 'required', + 'url', + ], ]; } diff --git a/app/NotificationChannels/Telegram.php b/app/NotificationChannels/Telegram.php index 017399b4..0ddae2ea 100644 --- a/app/NotificationChannels/Telegram.php +++ b/app/NotificationChannels/Telegram.php @@ -14,8 +14,12 @@ class Telegram extends AbstractNotificationChannel public function createRules(array $input): array { return [ - 'bot_token' => 'required|string', - 'chat_id' => 'required', + 'bot_token' => [ + 'required', + ], + 'chat_id' => [ + 'required', + ], ]; } diff --git a/app/Policies/NotificationChannelPolicy.php b/app/Policies/NotificationChannelPolicy.php new file mode 100644 index 00000000..f21c0573 --- /dev/null +++ b/app/Policies/NotificationChannelPolicy.php @@ -0,0 +1,37 @@ +<?php + +namespace App\Policies; + +use App\Models\NotificationChannel; +use App\Models\User; +use Illuminate\Auth\Access\HandlesAuthorization; + +class NotificationChannelPolicy +{ + use HandlesAuthorization; + + public function viewAny(User $user): bool + { + return $user->isAdmin(); + } + + public function view(User $user, NotificationChannel $notificationChannel): bool + { + return $user->isAdmin(); + } + + public function create(User $user): bool + { + return $user->isAdmin(); + } + + public function update(User $user, NotificationChannel $notificationChannel): bool + { + return $user->isAdmin(); + } + + public function delete(User $user, NotificationChannel $notificationChannel): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Policies/ScriptPolicy.php b/app/Policies/ScriptPolicy.php index 3003d9c2..c0f9d3e4 100644 --- a/app/Policies/ScriptPolicy.php +++ b/app/Policies/ScriptPolicy.php @@ -18,7 +18,7 @@ public function viewAny(User $user): bool public function view(User $user, Script $script): bool { - return $user->id === $script->user_id; + return $user->id === $script->user_id || $script->project?->users?->contains($user); } public function create(User $user): bool diff --git a/app/Providers/WebServiceProvider.php b/app/Providers/WebServiceProvider.php index ca260d0a..717e2fac 100644 --- a/app/Providers/WebServiceProvider.php +++ b/app/Providers/WebServiceProvider.php @@ -104,6 +104,7 @@ public function panel(Panel $panel): Panel Authenticate::class, ]) ->login() + ->spa() ->globalSearchKeyBindings(['command+k', 'ctrl+k']) ->sidebarCollapsibleOnDesktop() ->globalSearchFieldKeyBindingSuffix(); diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 735f92b1..7b1a05cb 100755 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -4,6 +4,8 @@ use App\Helpers\HtmxResponse; use Filament\Notifications\Actions\Action; use Filament\Notifications\Notification; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Route; function generate_public_key($privateKeyPath, $publicKeyPath): void { @@ -146,3 +148,22 @@ function tail($filepath, $lines = 1, $adaptive = true): string return trim($output); } + +function get_from_route(string $modelName, string $routeKey): mixed +{ + $model = request()->route($routeKey); + + if (! $model) { + $model = Route::getRoutes()->match(Request::create(url()->previous()))->parameter($routeKey); + } + + if ($model instanceof $modelName) { + return $model; + } + + if ($model) { + return $modelName::query()->find($model); + } + + return null; +} diff --git a/app/Web/Pages/Scripts/Executions.php b/app/Web/Pages/Scripts/Executions.php new file mode 100644 index 00000000..45e51308 --- /dev/null +++ b/app/Web/Pages/Scripts/Executions.php @@ -0,0 +1,86 @@ +<?php + +namespace App\Web\Pages\Scripts; + +use App\Actions\Script\ExecuteScript; +use App\Models\Script; +use App\Models\Server; +use App\Web\Components\Page; +use Filament\Actions\Action; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Get; +use Filament\Support\Enums\MaxWidth; +use Illuminate\Contracts\Support\Htmlable; + +class Executions extends Page +{ + protected static bool $shouldRegisterNavigation = false; + + protected static ?string $slug = 'scripts/{script}/executions'; + + public Script $script; + + public function getTitle(): string|Htmlable + { + return $this->script->name.' - Executions'; + } + + public static function canAccess(): bool + { + return auth()->user()?->can('view', get_from_route(Script::class, 'script')) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\ScriptExecutionsList::class, ['script' => $this->script]], + ]; + } + + protected function getHeaderActions(): array + { + $form = [ + Select::make('server') + ->options(function () { + return auth()->user()?->currentProject?->servers?->pluck('name', 'id') ?? []; + }) + ->rules(fn (Get $get) => ExecuteScript::rules($get())['server']) + ->searchable() + ->reactive(), + Select::make('user') + ->rules(fn (Get $get) => ExecuteScript::rules($get())['user']) + ->native(false) + ->options(function (Get $get) { + $options = [ + 'root' => 'root', + ]; + + $server = Server::query()->find($get('server')); + if ($server) { + $options[$server->ssh_user] = $server->ssh_user; + } + + return $options; + }), + ]; + + foreach ($this->script->getVariables() as $variable) { + $form[] = TextInput::make($variable) + ->label($variable) + ->rules(fn (Get $get) => ExecuteScript::rules($get())['variables.*']); + } + + return [ + Action::make('execute') + ->icon('heroicon-o-bolt') + ->modalWidth(MaxWidth::Large) + ->form($form) + ->action(function (array $data) { + app(ExecuteScript::class)->execute($this->script, $data); + + $this->dispatch('$refresh'); + }), + ]; + } +} diff --git a/app/Web/Pages/Scripts/Index.php b/app/Web/Pages/Scripts/Index.php new file mode 100644 index 00000000..d7300801 --- /dev/null +++ b/app/Web/Pages/Scripts/Index.php @@ -0,0 +1,74 @@ +<?php + +namespace App\Web\Pages\Scripts; + +use App\Actions\Script\CreateScript; +use App\Models\Script; +use App\Web\Components\Page; +use App\Web\Fields\CodeEditorField; +use Filament\Actions\Action; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\TextInput; +use Filament\Support\Enums\MaxWidth; + +class Index extends Page +{ + protected static ?string $slug = 'scripts'; + + protected static ?string $navigationIcon = 'heroicon-o-bolt'; + + protected static ?int $navigationSort = 2; + + protected static ?string $title = 'Scripts'; + + public static function getNavigationItemActiveRoutePattern(): string + { + return static::getRouteName().'*'; + } + + public static function canAccess(): bool + { + return auth()->user()?->can('viewAny', Script::class) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\ScriptsList::class], + ]; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('read-the-docs') + ->label('Read the Docs') + ->icon('heroicon-o-document-text') + ->color('gray') + ->url('https://vitodeploy.com/other/scripts.html') + ->openUrlInNewTab(), + Action::make('create') + ->label('Create a Script') + ->icon('heroicon-o-plus') + ->authorize('create', Script::class) + ->modalWidth(MaxWidth::ThreeExtraLarge) + ->form([ + TextInput::make('name') + ->rules(CreateScript::rules()['name']), + CodeEditorField::make('content') + ->rules(CreateScript::rules()['content']) + ->helperText('You can use variables like ${VARIABLE_NAME} in the script. The variables will be asked when executing the script'), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]) + ->modalSubmitActionLabel('Create') + ->action(function (array $data) { + run_action($this, function () use ($data) { + app(CreateScript::class)->create(auth()->user(), $data); + + $this->dispatch('$refresh'); + }); + }), + ]; + } +} diff --git a/app/Web/Pages/Scripts/Widgets/ScriptExecutionsList.php b/app/Web/Pages/Scripts/Widgets/ScriptExecutionsList.php new file mode 100644 index 00000000..ce06dcd9 --- /dev/null +++ b/app/Web/Pages/Scripts/Widgets/ScriptExecutionsList.php @@ -0,0 +1,79 @@ +<?php + +namespace App\Web\Pages\Scripts\Widgets; + +use App\Models\Script; +use App\Models\ScriptExecution; +use App\Web\Pages\Servers\View; +use Filament\Tables\Actions\Action; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget as Widget; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\View\ComponentAttributeBag; + +class ScriptExecutionsList extends Widget +{ + protected $listeners = ['$refresh']; + + public Script $script; + + protected function getTableQuery(): Builder + { + return ScriptExecution::query()->where('script_id', $this->script->id); + } + + protected function applyDefaultSortingToTableQuery(Builder $query): Builder + { + return $query->latest('created_at'); + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('server') + ->formatStateUsing(function (ScriptExecution $record) { + return $record->getServer()?->name ?? 'Unknown'; + }) + ->url(function (ScriptExecution $record) { + $server = $record->getServer(); + + return $server ? View::getUrl(['server' => $server]) : null; + }) + ->searchable() + ->sortable(), + TextColumn::make('created_at') + ->label('Executed At') + ->formatStateUsing(fn (ScriptExecution $record) => $record->created_at_by_timezone) + ->searchable() + ->sortable(), + TextColumn::make('status') + ->label('Status') + ->badge() + ->color(fn (ScriptExecution $record) => ScriptExecution::$statusColors[$record->status]) + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table + ->heading('') + ->actions([ + Action::make('view') + ->hiddenLabel() + ->tooltip('View') + ->icon('heroicon-o-eye') + ->authorize(fn (ScriptExecution $record) => auth()->user()->can('view', $record->serverLog)) + ->modalHeading('View Log') + ->modalContent(function (ScriptExecution $record) { + return view('components.console-view', [ + 'slot' => $record->serverLog?->getContent(), + 'attributes' => new ComponentAttributeBag, + ]); + }) + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close'), + ]); + } +} diff --git a/app/Web/Pages/Scripts/Widgets/ScriptsList.php b/app/Web/Pages/Scripts/Widgets/ScriptsList.php new file mode 100644 index 00000000..aa33a7df --- /dev/null +++ b/app/Web/Pages/Scripts/Widgets/ScriptsList.php @@ -0,0 +1,89 @@ +<?php + +namespace App\Web\Pages\Scripts\Widgets; + +use App\Actions\Script\EditScript; +use App\Models\Script; +use App\Web\Fields\CodeEditorField; +use App\Web\Pages\Scripts\Executions; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\TextInput; +use Filament\Support\Enums\MaxWidth; +use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Actions\EditAction; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget as Widget; +use Illuminate\Database\Eloquent\Builder; + +class ScriptsList extends Widget +{ + protected $listeners = ['$refresh']; + + protected function getTableQuery(): Builder + { + return Script::getByProjectId(auth()->user()->current_project_id, auth()->user()->id); + } + + protected function getTableColumns(): array + { + return [ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('id') + ->label('Global') + ->badge() + ->color(fn ($record) => $record->project_id ? 'gray' : 'success') + ->formatStateUsing(function (Script $record) { + return $record->project_id ? 'No' : 'Yes'; + }), + TextColumn::make('created_at') + ->label('Created At') + ->formatStateUsing(fn (Script $record) => $record->created_at_by_timezone) + ->searchable() + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table + ->heading('') + ->recordUrl(fn (Script $record) => Executions::getUrl(['script' => $record])) + ->actions([ + EditAction::make('edit') + ->label('Edit') + ->modalHeading('Edit Script') + ->mutateRecordDataUsing(function (array $data, Script $record) { + return [ + 'name' => $record->name, + 'content' => $record->content, + 'global' => $record->project_id === null, + ]; + }) + ->form([ + TextInput::make('name') + ->rules(EditScript::rules()['name']), + CodeEditorField::make('content') + ->rules(EditScript::rules()['content']) + ->helperText('You can use variables like ${VARIABLE_NAME} in the script. The variables will be asked when executing the script'), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]) + ->authorize(fn (Script $record) => auth()->user()->can('update', $record)) + ->using(function (array $data, Script $record) { + app(EditScript::class)->edit($record, auth()->user(), $data); + $this->dispatch('$refresh'); + }) + ->modalWidth(MaxWidth::ThreeExtraLarge), + DeleteAction::make('delete') + ->label('Delete') + ->modalHeading('Delete Script') + ->authorize(fn (Script $record) => auth()->user()->can('delete', $record)) + ->using(function (array $data, Script $record) { + $record->delete(); + }), + ]); + } +} diff --git a/app/Web/Pages/Servers/SSHKeys/Widgets/SshKeysList.php b/app/Web/Pages/Servers/SSHKeys/Widgets/SshKeysList.php index d34dc42e..57fcd277 100644 --- a/app/Web/Pages/Servers/SSHKeys/Widgets/SshKeysList.php +++ b/app/Web/Pages/Servers/SSHKeys/Widgets/SshKeysList.php @@ -7,7 +7,7 @@ use App\Models\SshKey; use Exception; use Filament\Notifications\Notification; -use Filament\Tables\Actions\Action; +use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Filament\Widgets\TableWidget; @@ -21,10 +21,11 @@ class SshKeysList extends TableWidget protected function getTableQuery(): Builder { - return SshKey::query()->whereHas( - 'servers', - fn (Builder $query) => $query->where('server_id', $this->server->id) - ); + return SshKey::withTrashed() + ->whereHas( + 'servers', + fn (Builder $query) => $query->where('server_id', $this->server->id) + ); } protected static ?string $heading = ''; @@ -47,14 +48,10 @@ public function getTable(): Table { return $this->table ->actions([ - Action::make('delete') - ->icon('heroicon-o-trash') - ->tooltip('Delete') - ->color('danger') + DeleteAction::make('delete') ->hiddenLabel() - ->requiresConfirmation() ->authorize(fn (SshKey $record) => auth()->user()->can('deleteServer', [SshKey::class, $this->server])) - ->action(function (SshKey $record) { + ->using(function (SshKey $record) { try { app(DeleteKeyFromServer::class)->delete($this->server, $record); } catch (Exception $e) { diff --git a/app/Web/Pages/Settings/NotificationChannels/Actions/Create.php b/app/Web/Pages/Settings/NotificationChannels/Actions/Create.php new file mode 100644 index 00000000..c7d66130 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Actions/Create.php @@ -0,0 +1,65 @@ +<?php + +namespace App\Web\Pages\Settings\NotificationChannels\Actions; + +use App\Actions\NotificationChannels\AddChannel; +use Exception; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Get; +use Filament\Notifications\Notification; + +class Create +{ + public static function form(): array + { + return [ + Select::make('provider') + ->options( + collect(config('core.notification_channels_providers')) + ->mapWithKeys(fn ($provider) => [$provider => $provider]) + ) + ->live() + ->reactive() + ->rules(fn (Get $get) => AddChannel::rules($get())['provider']), + TextInput::make('label') + ->rules(fn (Get $get) => AddChannel::rules($get())['label']), + TextInput::make('webhook_url') + ->label('Webhook URL') + ->validationAttribute('Webhook URL') + ->visible(fn (Get $get) => AddChannel::rules($get())['webhook_url'] ?? false) + ->rules(fn (Get $get) => AddChannel::rules($get())['webhook_url']), + TextInput::make('email') + ->visible(fn (Get $get) => AddChannel::rules($get())['email'] ?? false) + ->rules(fn (Get $get) => AddChannel::rules($get())['email']), + TextInput::make('bot_token') + ->label('Bot Token') + ->visible(fn (Get $get) => AddChannel::rules($get())['bot_token'] ?? false) + ->rules(fn (Get $get) => AddChannel::rules($get())['bot_token']), + TextInput::make('chat_id') + ->label('Chat ID') + ->visible(fn (Get $get) => AddChannel::rules($get())['chat_id'] ?? false) + ->rules(fn (Get $get) => AddChannel::rules($get())['chat_id']), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]; + } + + /** + * @throws Exception + */ + public static function action(array $data): void + { + try { + app(AddChannel::class)->add(auth()->user(), $data); + } catch (Exception $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + + throw $e; + } + } +} diff --git a/app/Web/Pages/Settings/NotificationChannels/Actions/Edit.php b/app/Web/Pages/Settings/NotificationChannels/Actions/Edit.php new file mode 100644 index 00000000..4544c26e --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Actions/Edit.php @@ -0,0 +1,27 @@ +<?php + +namespace App\Web\Pages\Settings\NotificationChannels\Actions; + +use App\Actions\NotificationChannels\EditChannel; +use App\Models\NotificationChannel; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Get; + +class Edit +{ + public static function form(): array + { + return [ + TextInput::make('label') + ->rules(fn (Get $get) => EditChannel::rules($get())['label']), + Checkbox::make('global') + ->label('Is Global (Accessible in all projects)'), + ]; + } + + public static function action(NotificationChannel $channel, array $data): void + { + app(EditChannel::class)->edit($channel, auth()->user(), $data); + } +} diff --git a/app/Web/Pages/Settings/NotificationChannels/Index.php b/app/Web/Pages/Settings/NotificationChannels/Index.php new file mode 100644 index 00000000..4bd74b6a --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Index.php @@ -0,0 +1,52 @@ +<?php + +namespace App\Web\Pages\Settings\NotificationChannels; + +use App\Models\NotificationChannel; +use App\Web\Components\Page; +use Filament\Actions\Action; +use Filament\Support\Enums\MaxWidth; + +class Index extends Page +{ + protected static ?string $navigationGroup = 'Settings'; + + protected static ?string $slug = 'settings/notification-channels'; + + protected static ?string $title = 'Notification Channels'; + + protected static ?string $navigationIcon = 'heroicon-o-bell'; + + protected static ?int $navigationSort = 8; + + public static function canAccess(): bool + { + return auth()->user()?->can('viewAny', NotificationChannel::class) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\NotificationChannelsList::class], + ]; + } + + protected function getHeaderActions(): array + { + return [ + Action::make('add') + ->label('Add new Channel') + ->icon('heroicon-o-plus') + ->modalHeading('Add a new Channel') + ->modalSubmitActionLabel('Add') + ->form(Actions\Create::form()) + ->authorize('create', NotificationChannel::class) + ->modalWidth(MaxWidth::Large) + ->action(function (array $data) { + Actions\Create::action($data); + + $this->dispatch('$refresh'); + }), + ]; + } +} diff --git a/app/Web/Pages/Settings/NotificationChannels/Widgets/NotificationChannelsList.php b/app/Web/Pages/Settings/NotificationChannels/Widgets/NotificationChannelsList.php new file mode 100644 index 00000000..5a401c11 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Widgets/NotificationChannelsList.php @@ -0,0 +1,77 @@ +<?php + +namespace App\Web\Pages\Settings\NotificationChannels\Widgets; + +use App\Models\NotificationChannel; +use App\Web\Pages\Settings\NotificationChannels\Actions\Edit; +use Filament\Support\Enums\MaxWidth; +use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Actions\EditAction; +use Filament\Tables\Columns\IconColumn; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget as Widget; +use Illuminate\Database\Eloquent\Builder; + +class NotificationChannelsList extends Widget +{ + protected $listeners = [ + '$refresh' => 'refreshTable', + ]; + + protected function getTableQuery(): Builder + { + return NotificationChannel::getByProjectId(auth()->user()->current_project_id); + } + + protected function getTableColumns(): array + { + return [ + IconColumn::make('provider') + ->icon(fn (NotificationChannel $record) => 'icon-'.$record->provider) + ->width(24), + TextColumn::make('label') + ->default(fn (NotificationChannel $record) => $record->label) + ->searchable() + ->sortable(), + TextColumn::make('id') + ->label('Global') + ->badge() + ->color(fn (NotificationChannel $record) => $record->project_id ? 'gray' : 'success') + ->formatStateUsing(function (NotificationChannel $record) { + return $record->project_id ? 'No' : 'Yes'; + }), + TextColumn::make('created_at') + ->label('Created At') + ->formatStateUsing(fn (NotificationChannel $record) => $record->created_at_by_timezone) + ->searchable() + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table + ->heading('') + ->actions([ + EditAction::make('edit') + ->modalHeading('Edit Notification Channel') + ->mutateRecordDataUsing(function (array $data, NotificationChannel $record) { + return [ + 'label' => $record->label, + 'global' => ! $record->project_id, + ]; + }) + ->form(Edit::form()) + ->authorize(fn (NotificationChannel $record) => auth()->user()->can('update', $record)) + ->using(fn (array $data, NotificationChannel $record) => Edit::action($record, $data)) + ->modalWidth(MaxWidth::Medium), + DeleteAction::make('delete') + ->modalHeading('Delete Notification Channel') + ->authorize(fn (NotificationChannel $record) => auth()->user()->can('delete', $record)) + ->using(function (array $data, NotificationChannel $record) { + // + }), + ]); + } +} diff --git a/app/Web/Pages/Settings/Projects/Settings.php b/app/Web/Pages/Settings/Projects/Settings.php index 4a4d338b..10e51e69 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\Pages\Servers\Page; +use App\Web\Components\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,9 +19,11 @@ 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; + return auth()->user()?->can('update', get_from_route(Project::class, 'project')) ?? false; } public Project $project; @@ -62,7 +64,7 @@ protected function getHeaderActions(): array try { app(DeleteProject::class)->delete(auth()->user(), $record); - $this->redirectRoute('filament.app.resources.projects.index'); + $this->redirectRoute(Index::getUrl()); } catch (Exception $e) { Notification::make() ->title($e->getMessage()) diff --git a/app/Web/Pages/Settings/SSHKeys/Index.php b/app/Web/Pages/Settings/SSHKeys/Index.php new file mode 100644 index 00000000..63576853 --- /dev/null +++ b/app/Web/Pages/Settings/SSHKeys/Index.php @@ -0,0 +1,63 @@ +<?php + +namespace App\Web\Pages\Settings\SSHKeys; + +use App\Actions\SshKey\CreateSshKey; +use App\Models\SshKey; +use App\Web\Components\Page; +use Filament\Actions\CreateAction; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Support\Enums\MaxWidth; + +class Index extends Page +{ + protected static ?string $navigationGroup = 'Settings'; + + protected static ?string $slug = 'settings/ssh-keys'; + + protected static ?string $title = 'SSH Keys'; + + protected static ?string $navigationIcon = 'heroicon-o-key'; + + protected static ?int $navigationSort = 9; + + public static function canAccess(): bool + { + return auth()->user()?->can('viewAny', SshKey::class) ?? false; + } + + public function getWidgets(): array + { + return [ + [Widgets\SshKeysList::class], + ]; + } + + protected function getHeaderActions(): array + { + return [ + CreateAction::make('add') + ->label('Add Key') + ->icon('heroicon-o-plus') + ->modalHeading('Add a new Key') + ->modalSubmitActionLabel('Add') + ->createAnother(false) + ->form([ + TextInput::make('name') + ->label('Name') + ->rules(CreateSshKey::rules()['name']), + Textarea::make('public_key') + ->label('Public Key') + ->rules(CreateSshKey::rules()['public_key']), + ]) + ->authorize('create', SshKey::class) + ->modalWidth(MaxWidth::Large) + ->using(function (array $data) { + app(CreateSshKey::class)->create(auth()->user(), $data); + + $this->dispatch('$refresh'); + }), + ]; + } +} diff --git a/app/Web/Pages/Settings/SSHKeys/Widgets/SshKeysList.php b/app/Web/Pages/Settings/SSHKeys/Widgets/SshKeysList.php new file mode 100644 index 00000000..2a7e5ef4 --- /dev/null +++ b/app/Web/Pages/Settings/SSHKeys/Widgets/SshKeysList.php @@ -0,0 +1,53 @@ +<?php + +namespace App\Web\Pages\Settings\SSHKeys\Widgets; + +use App\Models\SshKey; +use Filament\Tables\Actions\DeleteAction; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Table; +use Filament\Widgets\TableWidget; +use Illuminate\Database\Eloquent\Builder; + +class SshKeysList extends TableWidget +{ + protected $listeners = ['$refresh']; + + protected function getTableQuery(): Builder + { + return SshKey::query()->where('user_id', auth()->id()); + } + + protected static ?string $heading = ''; + + protected function getTableColumns(): array + { + return [ + TextColumn::make('name') + ->sortable() + ->searchable(), + TextColumn::make('public_key') + ->tooltip('Copy') + ->limit(20) + ->copyable(), + TextColumn::make('created_at') + ->sortable(), + ]; + } + + public function getTable(): Table + { + return $this->table + ->actions([ + DeleteAction::make('delete') + ->requiresConfirmation() + ->authorize(fn (SshKey $record) => auth()->user()->can('delete', $record)) + ->action(function (SshKey $record) { + run_action($this, function () use ($record) { + $record->delete(); + $this->dispatch('$refresh'); + }); + }), + ]); + } +} diff --git a/app/Web/Pages/Settings/Tags/Index.php b/app/Web/Pages/Settings/Tags/Index.php index 431201e5..fca9a642 100644 --- a/app/Web/Pages/Settings/Tags/Index.php +++ b/app/Web/Pages/Settings/Tags/Index.php @@ -18,7 +18,7 @@ class Index extends Page protected static ?string $navigationIcon = 'heroicon-o-tag'; - protected static ?int $navigationSort = 7; + protected static ?int $navigationSort = 10; public static function canAccess(): bool { diff --git a/database/migrations/2024_10_06_160213_add_soft_deletes_to_ssh_keys.php b/database/migrations/2024_10_06_160213_add_soft_deletes_to_ssh_keys.php new file mode 100644 index 00000000..50b8092b --- /dev/null +++ b/database/migrations/2024_10_06_160213_add_soft_deletes_to_ssh_keys.php @@ -0,0 +1,22 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('ssh_keys', function (Blueprint $table) { + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::table('ssh_keys', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/database/migrations/2024_10_06_162253_add_project_id_to_scripts.php b/database/migrations/2024_10_06_162253_add_project_id_to_scripts.php new file mode 100644 index 00000000..3739255e --- /dev/null +++ b/database/migrations/2024_10_06_162253_add_project_id_to_scripts.php @@ -0,0 +1,22 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('scripts', function (Blueprint $table) { + $table->unsignedInteger('project_id')->nullable(); + }); + } + + public function down(): void + { + Schema::table('scripts', function (Blueprint $table) { + $table->dropColumn('project_id'); + }); + } +}; diff --git a/database/migrations/2024_10_06_174115_add_server_id_to_script_executions.php b/database/migrations/2024_10_06_174115_add_server_id_to_script_executions.php new file mode 100644 index 00000000..8dd0fc6c --- /dev/null +++ b/database/migrations/2024_10_06_174115_add_server_id_to_script_executions.php @@ -0,0 +1,22 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::table('script_executions', function (Blueprint $table) { + $table->unsignedInteger('server_id')->nullable(); + }); + } + + public function down(): void + { + Schema::table('script_executions', function (Blueprint $table) { + $table->dropColumn('server_id'); + }); + } +};