From 8b2338cb411d88f22918ac6028d28f688d1d99aa Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 6 Oct 2024 20:49:59 +0200 Subject: [PATCH] - 2.x - scripts --- .../NotificationChannels/AddChannel.php | 36 ++++---- .../NotificationChannels/EditChannel.php | 23 ++--- app/Actions/Script/CreateScript.php | 10 +-- app/Actions/Script/EditScript.php | 14 +-- app/Actions/Script/ExecuteScript.php | 29 +++--- app/Models/Script.php | 25 ++++++ app/Models/ScriptExecution.php | 32 ++++++- app/Models/SshKey.php | 2 + app/NotificationChannels/Discord.php | 5 +- app/NotificationChannels/Email.php | 5 +- app/NotificationChannels/Slack.php | 5 +- app/NotificationChannels/Telegram.php | 8 +- app/Policies/NotificationChannelPolicy.php | 37 ++++++++ app/Policies/ScriptPolicy.php | 2 +- app/Providers/WebServiceProvider.php | 1 + app/Support/helpers.php | 21 +++++ app/Web/Pages/Scripts/Executions.php | 86 ++++++++++++++++++ app/Web/Pages/Scripts/Index.php | 74 +++++++++++++++ .../Scripts/Widgets/ScriptExecutionsList.php | 79 ++++++++++++++++ app/Web/Pages/Scripts/Widgets/ScriptsList.php | 89 +++++++++++++++++++ .../Servers/SSHKeys/Widgets/SshKeysList.php | 19 ++-- .../NotificationChannels/Actions/Create.php | 65 ++++++++++++++ .../NotificationChannels/Actions/Edit.php | 27 ++++++ .../Settings/NotificationChannels/Index.php | 52 +++++++++++ .../Widgets/NotificationChannelsList.php | 77 ++++++++++++++++ app/Web/Pages/Settings/Projects/Settings.php | 8 +- app/Web/Pages/Settings/SSHKeys/Index.php | 63 +++++++++++++ .../Settings/SSHKeys/Widgets/SshKeysList.php | 53 +++++++++++ app/Web/Pages/Settings/Tags/Index.php | 2 +- ...06_160213_add_soft_deletes_to_ssh_keys.php | 22 +++++ ...10_06_162253_add_project_id_to_scripts.php | 22 +++++ ...115_add_server_id_to_script_executions.php | 22 +++++ 32 files changed, 936 insertions(+), 79 deletions(-) create mode 100644 app/Policies/NotificationChannelPolicy.php create mode 100644 app/Web/Pages/Scripts/Executions.php create mode 100644 app/Web/Pages/Scripts/Index.php create mode 100644 app/Web/Pages/Scripts/Widgets/ScriptExecutionsList.php create mode 100644 app/Web/Pages/Scripts/Widgets/ScriptsList.php create mode 100644 app/Web/Pages/Settings/NotificationChannels/Actions/Create.php create mode 100644 app/Web/Pages/Settings/NotificationChannels/Actions/Edit.php create mode 100644 app/Web/Pages/Settings/NotificationChannels/Index.php create mode 100644 app/Web/Pages/Settings/NotificationChannels/Widgets/NotificationChannelsList.php create mode 100644 app/Web/Pages/Settings/SSHKeys/Index.php create mode 100644 app/Web/Pages/Settings/SSHKeys/Widgets/SshKeysList.php create mode 100644 database/migrations/2024_10_06_160213_add_soft_deletes_to_ssh_keys.php create mode 100644 database/migrations/2024_10_06_162253_add_project_id_to_scripts.php create mode 100644 database/migrations/2024_10_06_174115_add_server_id_to_script_executions.php diff --git a/app/Actions/NotificationChannels/AddChannel.php b/app/Actions/NotificationChannels/AddChannel.php index 11e98ae..5e3ef93 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 fbba3de..1404930 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 7e155b0..0734868 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 b025128..b155354 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 5c601e1..ca2dd0a 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 1f3b35e..e530637 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 $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 e7d5fda..6b8c1e9 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 dcb27e5..cf42084 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 f41e258..3807f20 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 6ff6006..389d505 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 3bc28cd..e20fffa 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 017399b..0ddae2e 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 0000000..f21c057 --- /dev/null +++ b/app/Policies/NotificationChannelPolicy.php @@ -0,0 +1,37 @@ +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 3003d9c..c0f9d3e 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 ca260d0..717e2fa 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 735f92b..7b1a05c 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 0000000..45e5130 --- /dev/null +++ b/app/Web/Pages/Scripts/Executions.php @@ -0,0 +1,86 @@ +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 0000000..d730080 --- /dev/null +++ b/app/Web/Pages/Scripts/Index.php @@ -0,0 +1,74 @@ +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 0000000..ce06dcd --- /dev/null +++ b/app/Web/Pages/Scripts/Widgets/ScriptExecutionsList.php @@ -0,0 +1,79 @@ +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 0000000..aa33a7d --- /dev/null +++ b/app/Web/Pages/Scripts/Widgets/ScriptsList.php @@ -0,0 +1,89 @@ +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 d34dc42..57fcd27 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 0000000..c7d6613 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Actions/Create.php @@ -0,0 +1,65 @@ +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 0000000..4544c26 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Actions/Edit.php @@ -0,0 +1,27 @@ +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 0000000..4bd74b6 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Index.php @@ -0,0 +1,52 @@ +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 0000000..5a401c1 --- /dev/null +++ b/app/Web/Pages/Settings/NotificationChannels/Widgets/NotificationChannelsList.php @@ -0,0 +1,77 @@ + '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 4a4d338..10e51e6 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 0000000..6357685 --- /dev/null +++ b/app/Web/Pages/Settings/SSHKeys/Index.php @@ -0,0 +1,63 @@ +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 0000000..2a7e5ef --- /dev/null +++ b/app/Web/Pages/Settings/SSHKeys/Widgets/SshKeysList.php @@ -0,0 +1,53 @@ +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 431201e..fca9a64 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 0000000..50b8092 --- /dev/null +++ b/database/migrations/2024_10_06_160213_add_soft_deletes_to_ssh_keys.php @@ -0,0 +1,22 @@ +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 0000000..3739255 --- /dev/null +++ b/database/migrations/2024_10_06_162253_add_project_id_to_scripts.php @@ -0,0 +1,22 @@ +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 0000000..8dd0fc6 --- /dev/null +++ b/database/migrations/2024_10_06_174115_add_server_id_to_script_executions.php @@ -0,0 +1,22 @@ +unsignedInteger('server_id')->nullable(); + }); + } + + public function down(): void + { + Schema::table('script_executions', function (Blueprint $table) { + $table->dropColumn('server_id'); + }); + } +};