From 118a039bf5d2303f2231445e3eaceb909725da9d Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 14:28:31 +0100 Subject: [PATCH 01/20] add view page for egg admin resource --- .../Admin/Resources/Eggs/EggResource.php | 264 ++++++++++++++++++ .../Admin/Resources/Eggs/Pages/EditEgg.php | 253 +---------------- .../Admin/Resources/Eggs/Pages/ViewEgg.php | 49 ++++ 3 files changed, 314 insertions(+), 252 deletions(-) create mode 100644 app/Filament/Admin/Resources/Eggs/Pages/ViewEgg.php diff --git a/app/Filament/Admin/Resources/Eggs/EggResource.php b/app/Filament/Admin/Resources/Eggs/EggResource.php index 4031b410c8..52626095f4 100644 --- a/app/Filament/Admin/Resources/Eggs/EggResource.php +++ b/app/Filament/Admin/Resources/Eggs/EggResource.php @@ -3,18 +3,41 @@ namespace App\Filament\Admin\Resources\Eggs; use App\Enums\CustomizationKey; +use App\Enums\EditorLanguages; use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Eggs\Pages\CreateEgg; use App\Filament\Admin\Resources\Eggs\Pages\EditEgg; use App\Filament\Admin\Resources\Eggs\Pages\ListEggs; +use App\Filament\Admin\Resources\Eggs\Pages\ViewEgg; use App\Filament\Admin\Resources\Eggs\RelationManagers\ServersRelationManager; +use App\Filament\Components\Actions\DeleteIcon; +use App\Filament\Components\Actions\UploadIcon; +use App\Filament\Components\Forms\Fields\CopyFrom; +use App\Filament\Components\Forms\Fields\MonacoEditor; use App\Models\Egg; +use App\Models\EggVariable; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; use BackedEnum; +use Filament\Forms\Components\Checkbox; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\Resource; +use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Image; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; +use Illuminate\Validation\Rules\Unique; class EggResource extends Resource { @@ -71,7 +94,248 @@ public static function getDefaultPages(): array return [ 'index' => ListEggs::route('/'), 'create' => CreateEgg::route('/create'), + 'view' => ViewEgg::route('/{record}'), 'edit' => EditEgg::route('/{record}/edit'), ]; } + + /** @return Tab[] */ + public static function detailTabs(): array + { + return [ + Tab::make('configuration') + ->label(trans('admin/egg.tabs.configuration')) + ->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6]) + ->icon(TablerIcon::Egg) + ->schema([ + Grid::make(2) + ->columnStart(1) + ->schema([ + Image::make('', 'icon') + ->hidden(fn ($record) => !$record->icon) + ->url(fn ($record) => $record->icon) + ->imageSize(150) + ->columnSpanFull() + ->alignJustify(), + UploadIcon::make() + ->hidden(fn (string $operation) => $operation === 'view'), + DeleteIcon::make() + ->iconStoragePath(Egg::getIconStoragePath()) + ->hidden(fn (string $operation) => $operation === 'view'), + ]), + TextInput::make('name') + ->label(trans('admin/egg.name')) + ->required() + ->maxLength(255) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2]) + ->helperText(trans('admin/egg.name_help')), + Textarea::make('description') + ->label(trans('admin/egg.description')) + ->rows(3) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3]) + ->helperText(trans('admin/egg.description_help')), + TextInput::make('id') + ->label(trans('admin/egg.egg_id')) + ->columnSpan(1) + ->disabled(), + TextInput::make('uuid') + ->label(trans('admin/egg.egg_uuid')) + ->disabled() + ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->helperText(trans('admin/egg.uuid_help')), + TextInput::make('author') + ->label(trans('admin/egg.author')) + ->required() + ->maxLength(255) + ->email() + ->disabled() + ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) + ->helperText(trans('admin/egg.author_help_edit')), + Toggle::make('force_outgoing_ip') + ->inline(false) + ->label(trans('admin/egg.force_ip')) + ->columnSpan(1) + ->hintIcon(TablerIcon::QuestionMark, trans('admin/egg.force_ip_help')), + KeyValue::make('startup_commands') + ->label(trans('admin/egg.startup_commands')) + ->live() + ->columnSpanFull() + ->required() + ->reorderable() + ->addActionLabel(trans('admin/egg.add_startup')) + ->keyLabel(trans('admin/egg.startup_name')) + ->valueLabel(trans('admin/egg.startup_command')) + ->helperText(trans('admin/egg.startup_help')), + TagsInput::make('file_denylist') + ->label(trans('admin/egg.file_denylist')) + ->placeholder('denied-file.txt') + ->helperText(trans('admin/egg.file_denylist_help')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + TextInput::make('update_url') + ->label(trans('admin/egg.update_url')) + ->url() + ->hintIcon(TablerIcon::QuestionMark, trans('admin/egg.update_url_help')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + TagsInput::make('features') + ->label(trans('admin/egg.features')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + Hidden::make('script_is_privileged') + ->helperText('The docker images available to servers using this egg.'), + TagsInput::make('tags') + ->label(trans('admin/egg.tags')) + ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), + KeyValue::make('docker_images') + ->label(trans('admin/egg.docker_images')) + ->live() + ->columnSpanFull() + ->required() + ->reorderable() + ->addActionLabel(trans('admin/egg.add_image')) + ->keyLabel(trans('admin/egg.docker_name')) + ->valueLabel(trans('admin/egg.docker_uri')) + ->helperText(trans('admin/egg.docker_help')), + ]), + Tab::make('process_management') + ->label(trans('admin/egg.tabs.process_management')) + ->columns() + ->icon(TablerIcon::ServerCog) + ->schema([ + CopyFrom::make('copy_process_from') + ->process() + ->hidden(fn (string $operation) => $operation === 'view'), + TextInput::make('config_stop') + ->label(trans('admin/egg.stop_command')) + ->maxLength(255) + ->helperText(trans('admin/egg.stop_command_help')), + Textarea::make('config_startup')->rows(10)->json() + ->label(trans('admin/egg.start_config')) + ->helperText(trans('admin/egg.start_config_help')), + Textarea::make('config_files')->rows(10)->json() + ->label(trans('admin/egg.config_files')) + ->dehydrateStateUsing(fn ($state) => blank($state) ? '{}' : $state) + ->helperText(trans('admin/egg.config_files_help')), + Textarea::make('config_logs')->rows(10)->json() + ->label(trans('admin/egg.log_config')) + ->helperText(trans('admin/egg.log_config_help')), + ]), + Tab::make('egg_variables') + ->label(trans('admin/egg.tabs.egg_variables')) + ->columnSpanFull() + ->icon(TablerIcon::Variable) + ->schema([ + Repeater::make('variables') + ->hiddenLabel() + ->grid() + ->relationship('variables') + ->orderColumn() + ->collapsible()->collapsed() + ->addActionLabel(trans('admin/egg.add_new_variable')) + ->itemLabel(fn (array $state) => $state['name']) + ->mutateRelationshipDataBeforeCreateUsing(function (array $data): array { + $data['default_value'] ??= ''; + $data['description'] ??= ''; + $data['rules'] ??= []; + $data['user_viewable'] ??= ''; + $data['user_editable'] ??= ''; + + return $data; + }) + ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { + $data['default_value'] ??= ''; + $data['description'] ??= ''; + $data['rules'] ??= []; + $data['user_viewable'] ??= ''; + $data['user_editable'] ??= ''; + + return $data; + }) + ->schema([ + TextInput::make('name') + ->label(trans('admin/egg.name')) + ->live() + ->debounce(750) + ->maxLength(255) + ->columnSpanFull() + ->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())) + ->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id'))) + ->validationMessages([ + 'unique' => trans('admin/egg.error_unique'), + ]) + ->required(), + Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(), + TextInput::make('env_variable') + ->label(trans('admin/egg.environment_variable')) + ->maxLength(255) + ->prefix('{{') + ->suffix('}}') + ->hintIcon(TablerIcon::Code, fn ($state) => "{{{$state}}}") + ->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id'))) + ->rules(EggVariable::getRulesForField('env_variable')) + ->validationMessages([ + 'unique' => trans('admin/egg.error_unique'), + 'required' => trans('admin/egg.error_required'), + '*' => trans('admin/egg.error_reserved'), + ]) + ->required(), + TextInput::make('default_value')->label(trans('admin/egg.default_value')), + Fieldset::make(trans('admin/egg.user_permissions')) + ->schema([ + Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')), + Checkbox::make('user_editable')->label(trans('admin/egg.editable')), + ]), + TagsInput::make('rules') + ->label(trans('admin/egg.rules')) + ->columnSpanFull() + ->reorderable() + ->suggestions([ + 'required', + 'nullable', + 'string', + 'integer', + 'numeric', + 'boolean', + 'alpha', + 'alpha_dash', + 'alpha_num', + 'url', + 'email', + 'regex:', + 'min:', + 'max:', + 'between:', + 'between:1024,65535', + 'in:', + 'in:true,false', + ]), + ]), + ]), + Tab::make('install_script') + ->label(trans('admin/egg.tabs.install_script')) + ->columns(3) + ->icon(TablerIcon::FileDownload) + ->schema([ + CopyFrom::make('copy_script_from') + ->script() + ->hidden(fn (string $operation) => $operation === 'view'), + TextInput::make('script_container') + ->label(trans('admin/egg.script_container')) + ->required() + ->maxLength(255) + ->placeholder('ghcr.io/pelican-eggs/installers:debian'), + Select::make('script_entry') + ->label(trans('admin/egg.script_entry')) + ->selectablePlaceholder(false) + ->options([ + 'bash' => 'bash', + 'ash' => 'ash', + '/bin/bash' => '/bin/bash', + ]) + ->required(), + MonacoEditor::make('script_install') + ->hiddenLabel() + ->language(EditorLanguages::shell) + ->columnSpanFull(), + ]), + ]; + } } diff --git a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php index 023f212c9b..a2c8d05ff1 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/EditEgg.php @@ -2,42 +2,21 @@ namespace App\Filament\Admin\Resources\Eggs\Pages; -use App\Enums\EditorLanguages; use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Eggs\EggResource; -use App\Filament\Components\Actions\DeleteIcon; use App\Filament\Components\Actions\ExportEggAction; use App\Filament\Components\Actions\ImportEggAction; -use App\Filament\Components\Actions\UploadIcon; -use App\Filament\Components\Forms\Fields\CopyFrom; -use App\Filament\Components\Forms\Fields\MonacoEditor; use App\Models\Egg; -use App\Models\EggVariable; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; -use Filament\Forms\Components\Checkbox; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Repeater; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TagsInput; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; use Filament\Resources\Pages\EditRecord; -use Filament\Schemas\Components\Fieldset; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Image; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Filament\Schemas\Components\Utilities\Get; -use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; -use Illuminate\Validation\Rules\Unique; class EditEgg extends EditRecord { @@ -61,237 +40,7 @@ public function form(Schema $schema): Schema /** @return Tab[] */ protected function getDefaultTabs(): array { - return [ - Tab::make('configuration') - ->label(trans('admin/egg.tabs.configuration')) - ->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6]) - ->icon(TablerIcon::Egg) - ->schema([ - Grid::make(2) - ->columnStart(1) - ->schema([ - Image::make('', 'icon') - ->hidden(fn ($record) => !$record->icon) - ->url(fn ($record) => $record->icon) - ->imageSize(150) - ->columnSpanFull() - ->alignJustify(), - UploadIcon::make(), - DeleteIcon::make() - ->iconStoragePath(Egg::getIconStoragePath()), - ]), - TextInput::make('name') - ->label(trans('admin/egg.name')) - ->required() - ->maxLength(255) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2]) - ->helperText(trans('admin/egg.name_help')), - Textarea::make('description') - ->label(trans('admin/egg.description')) - ->rows(3) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3]) - ->helperText(trans('admin/egg.description_help')), - TextInput::make('id') - ->label(trans('admin/egg.egg_id')) - ->columnSpan(1) - ->disabled(), - TextInput::make('uuid') - ->label(trans('admin/egg.egg_uuid')) - ->disabled() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) - ->helperText(trans('admin/egg.uuid_help')), - TextInput::make('author') - ->label(trans('admin/egg.author')) - ->required() - ->maxLength(255) - ->email() - ->disabled() - ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2]) - ->helperText(trans('admin/egg.author_help_edit')), - Toggle::make('force_outgoing_ip') - ->inline(false) - ->label(trans('admin/egg.force_ip')) - ->columnSpan(1) - ->hintIcon(TablerIcon::QuestionMark, trans('admin/egg.force_ip_help')), - KeyValue::make('startup_commands') - ->label(trans('admin/egg.startup_commands')) - ->live() - ->columnSpanFull() - ->required() - ->reorderable() - ->addActionLabel(trans('admin/egg.add_startup')) - ->keyLabel(trans('admin/egg.startup_name')) - ->valueLabel(trans('admin/egg.startup_command')) - ->helperText(trans('admin/egg.startup_help')), - TagsInput::make('file_denylist') - ->label(trans('admin/egg.file_denylist')) - ->placeholder('denied-file.txt') - ->helperText(trans('admin/egg.file_denylist_help')) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), - TextInput::make('update_url') - ->label(trans('admin/egg.update_url')) - ->url() - ->hintIcon(TablerIcon::QuestionMark, trans('admin/egg.update_url_help')) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), - TagsInput::make('features') - ->label(trans('admin/egg.features')) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), - Hidden::make('script_is_privileged') - ->helperText('The docker images available to servers using this egg.'), - TagsInput::make('tags') - ->label(trans('admin/egg.tags')) - ->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]), - KeyValue::make('docker_images') - ->label(trans('admin/egg.docker_images')) - ->live() - ->columnSpanFull() - ->required() - ->reorderable() - ->addActionLabel(trans('admin/egg.add_image')) - ->keyLabel(trans('admin/egg.docker_name')) - ->valueLabel(trans('admin/egg.docker_uri')) - ->helperText(trans('admin/egg.docker_help')), - ]), - Tab::make('process_management') - ->label(trans('admin/egg.tabs.process_management')) - ->columns() - ->icon(TablerIcon::ServerCog) - ->schema([ - CopyFrom::make('copy_process_from') - ->process(), - TextInput::make('config_stop') - ->label(trans('admin/egg.stop_command')) - ->maxLength(255) - ->helperText(trans('admin/egg.stop_command_help')), - Textarea::make('config_startup')->rows(10)->json() - ->label(trans('admin/egg.start_config')) - ->helperText(trans('admin/egg.start_config_help')), - Textarea::make('config_files')->rows(10)->json() - ->label(trans('admin/egg.config_files')) - ->dehydrateStateUsing(fn ($state) => blank($state) ? '{}' : $state) - ->helperText(trans('admin/egg.config_files_help')), - Textarea::make('config_logs')->rows(10)->json() - ->label(trans('admin/egg.log_config')) - ->helperText(trans('admin/egg.log_config_help')), - ]), - Tab::make('egg_variables') - ->label(trans('admin/egg.tabs.egg_variables')) - ->columnSpanFull() - ->icon(TablerIcon::Variable) - ->schema([ - Repeater::make('variables') - ->hiddenLabel() - ->grid() - ->relationship('variables') - ->orderColumn() - ->collapsible()->collapsed() - ->addActionLabel(trans('admin/egg.add_new_variable')) - ->itemLabel(fn (array $state) => $state['name']) - ->mutateRelationshipDataBeforeCreateUsing(function (array $data): array { - $data['default_value'] ??= ''; - $data['description'] ??= ''; - $data['rules'] ??= []; - $data['user_viewable'] ??= ''; - $data['user_editable'] ??= ''; - - return $data; - }) - ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { - $data['default_value'] ??= ''; - $data['description'] ??= ''; - $data['rules'] ??= []; - $data['user_viewable'] ??= ''; - $data['user_editable'] ??= ''; - - return $data; - }) - ->schema([ - TextInput::make('name') - ->label(trans('admin/egg.name')) - ->live() - ->debounce(750) - ->maxLength(255) - ->columnSpanFull() - ->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString())) - ->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id'))) - ->validationMessages([ - 'unique' => trans('admin/egg.error_unique'), - ]) - ->required(), - Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(), - TextInput::make('env_variable') - ->label(trans('admin/egg.environment_variable')) - ->maxLength(255) - ->prefix('{{') - ->suffix('}}') - ->hintIcon(TablerIcon::Code, fn ($state) => "{{{$state}}}") - ->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id'))) - ->rules(EggVariable::getRulesForField('env_variable')) - ->validationMessages([ - 'unique' => trans('admin/egg.error_unique'), - 'required' => trans('admin/egg.error_required'), - '*' => trans('admin/egg.error_reserved'), - ]) - ->required(), - TextInput::make('default_value')->label(trans('admin/egg.default_value')), - Fieldset::make(trans('admin/egg.user_permissions')) - ->schema([ - Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')), - Checkbox::make('user_editable')->label(trans('admin/egg.editable')), - ]), - TagsInput::make('rules') - ->label(trans('admin/egg.rules')) - ->columnSpanFull() - ->reorderable() - ->suggestions([ - 'required', - 'nullable', - 'string', - 'integer', - 'numeric', - 'boolean', - 'alpha', - 'alpha_dash', - 'alpha_num', - 'url', - 'email', - 'regex:', - 'min:', - 'max:', - 'between:', - 'between:1024,65535', - 'in:', - 'in:true,false', - ]), - ]), - ]), - Tab::make('install_script') - ->label(trans('admin/egg.tabs.install_script')) - ->columns(3) - ->icon(TablerIcon::FileDownload) - ->schema([ - CopyFrom::make('copy_script_from') - ->script(), - TextInput::make('script_container') - ->label(trans('admin/egg.script_container')) - ->required() - ->maxLength(255) - ->placeholder('ghcr.io/pelican-eggs/installers:debian'), - Select::make('script_entry') - ->label(trans('admin/egg.script_entry')) - ->selectablePlaceholder(false) - ->options([ - 'bash' => 'bash', - 'ash' => 'ash', - '/bin/bash' => '/bin/bash', - ]) - ->required(), - MonacoEditor::make('script_install') - ->hiddenLabel() - ->language(EditorLanguages::shell) - ->columnSpanFull(), - ]), - ]; + return EggResource::detailTabs(); } /** @return array */ diff --git a/app/Filament/Admin/Resources/Eggs/Pages/ViewEgg.php b/app/Filament/Admin/Resources/Eggs/Pages/ViewEgg.php new file mode 100644 index 0000000000..18509311ad --- /dev/null +++ b/app/Filament/Admin/Resources/Eggs/Pages/ViewEgg.php @@ -0,0 +1,49 @@ +components([ + Tabs::make() + ->tabs($this->getTabs()) + ->columnSpanFull() + ->persistTabInQueryString(), + ]); + } + + /** @return Tab[] */ + protected function getDefaultTabs(): array + { + return EggResource::detailTabs(); + } + + /** @return array */ + protected function getDefaultHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } +} From 5d4cb1d6efbf8c87a508d5b5d65718a14c6e1c2d Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 15:04:36 +0100 Subject: [PATCH 02/20] add view row action to egg list table --- app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php index a587328f3f..ae2b5375ef 100644 --- a/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php +++ b/app/Filament/Admin/Resources/Eggs/Pages/ListEggs.php @@ -18,6 +18,7 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Actions\ReplicateAction; +use Filament\Actions\ViewAction; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Columns\ImageColumn; @@ -65,6 +66,9 @@ public function table(Table $table): Table ->label(trans('admin/egg.servers')), ]) ->recordActions([ + ViewAction::make() + ->tooltip(trans('filament-actions::view.single.label')) + ->hidden(fn (Egg $record) => EggResource::getEditAuthorizationResponse($record)->allowed()), EditAction::make() ->tooltip(trans('filament-actions::edit.single.label')), ExportEggAction::make() From b59335c112d6f79b39adf859af8df08146a9decd Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 15:58:29 +0100 Subject: [PATCH 03/20] test the egg view page --- tests/Filament/Admin/ViewEggTest.php | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/Filament/Admin/ViewEggTest.php diff --git a/tests/Filament/Admin/ViewEggTest.php b/tests/Filament/Admin/ViewEggTest.php new file mode 100644 index 0000000000..11701a9020 --- /dev/null +++ b/tests/Filament/Admin/ViewEggTest.php @@ -0,0 +1,119 @@ +create(['name' => $name, 'guard_name' => 'web']); + + foreach ($abilities as $ability) { + $role->givePermissionTo(Permission::findOrCreate($ability, 'web')); + } + + return $role; +} + +it('lets a user with view permission open the view page', function () { + $egg = Egg::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(eggRole('Egg Viewer', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + ])); + + $this->actingAs($user); + livewire(ViewEgg::class, ['record' => $egg->getKey()]) + ->assertSuccessful(); +}); + +it('forbids the view page without view permission', function () { + $egg = Egg::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(eggRole('Node Viewer', [ + RolePermissionModels::Node->view(), + ])); + + $this->actingAs($user); + livewire(ViewEgg::class, ['record' => $egg->getKey()]) + ->assertForbidden(); +}); + +it('forbids the edit page for a view-only user', function () { + $egg = Egg::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(eggRole('Egg Viewer', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + ])); + + $this->actingAs($user); + livewire(EditEgg::class, ['record' => $egg->getKey()]) + ->assertForbidden(); +}); + +it('hides the icon action on the view page but keeps it on edit', function () { + $egg = Egg::factory()->create(); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(eggRole('Egg Editor', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + RolePermissionModels::Egg->update(), + ])); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(eggRole('Egg Viewer', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + ])); + + $this->actingAs($editor); + livewire(EditEgg::class, ['record' => $egg->getKey()]) + ->assertActionVisible(TestAction::make('upload_icon')->schemaComponent(true)); + + $this->actingAs($viewer); + livewire(ViewEgg::class, ['record' => $egg->getKey()]) + ->assertActionDoesNotExist(TestAction::make('upload_icon')->schemaComponent(true)); +}); + +it('shows the view row action only when the user cannot edit', function () { + $egg = Egg::factory()->create(); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(eggRole('Egg Viewer', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + ])); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(eggRole('Egg Editor', [ + RolePermissionModels::Egg->viewAny(), + RolePermissionModels::Egg->view(), + RolePermissionModels::Egg->update(), + ])); + + // table action urls resolve against the current panel; the default is 'app', not 'admin' + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $this->actingAs($viewer); + livewire(ListEggs::class) + ->assertTableActionVisible('view', $egg); + + $this->actingAs($editor); + livewire(ListEggs::class) + ->assertTableActionHidden('view', $egg); +}); From b359a8b5710bb0c4056e880e1425e01e1462ddc9 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 16:06:15 +0100 Subject: [PATCH 04/20] make monaco editor read-only when disabled --- resources/views/filament/components/monaco-editor.blade.php | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/views/filament/components/monaco-editor.blade.php b/resources/views/filament/components/monaco-editor.blade.php index 5f1892aeac..f7d785ce94 100644 --- a/resources/views/filament/components/monaco-editor.blade.php +++ b/resources/views/filament/components/monaco-editor.blade.php @@ -88,6 +88,7 @@ }, wordWrap: 'on', wrappingIndent: 'same', + readOnly: {{ $isDisabled() ? 'true' : 'false' }}, }); $el.style.zIndex = '1'; monacoEditor(document.getElementById(monacoId).editor); From 030233828e13395ff4e43357724fac337b19f727 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 18:53:48 +0100 Subject: [PATCH 05/20] add view page for node admin resource --- .../Admin/Resources/Nodes/NodeResource.php | 2 + .../Admin/Resources/Nodes/Pages/EditNode.php | 760 +---------------- .../Admin/Resources/Nodes/Pages/ViewNode.php | 68 ++ app/Traits/Filament/NodeDetailTabs.php | 786 ++++++++++++++++++ 4 files changed, 859 insertions(+), 757 deletions(-) create mode 100644 app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php create mode 100644 app/Traits/Filament/NodeDetailTabs.php diff --git a/app/Filament/Admin/Resources/Nodes/NodeResource.php b/app/Filament/Admin/Resources/Nodes/NodeResource.php index 426a2895fc..7b413d8da3 100644 --- a/app/Filament/Admin/Resources/Nodes/NodeResource.php +++ b/app/Filament/Admin/Resources/Nodes/NodeResource.php @@ -7,6 +7,7 @@ use App\Filament\Admin\Resources\Nodes\Pages\CreateNode; use App\Filament\Admin\Resources\Nodes\Pages\EditNode; use App\Filament\Admin\Resources\Nodes\Pages\ListNodes; +use App\Filament\Admin\Resources\Nodes\Pages\ViewNode; use App\Filament\Admin\Resources\Nodes\RelationManagers\AllocationsRelationManager; use App\Filament\Admin\Resources\Nodes\RelationManagers\ServersRelationManager; use App\Models\Node; @@ -69,6 +70,7 @@ public static function getDefaultPages(): array return [ 'index' => ListNodes::route('/'), 'create' => CreateNode::route('/create'), + 'view' => ViewNode::route('/{record}'), 'edit' => EditNode::route('/{record}/edit'), ]; } diff --git a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php index eb3b8e7f39..853f689457 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php @@ -6,49 +6,27 @@ use App\Filament\Admin\Resources\Nodes\NodeResource; use App\Models\Node; use App\Repositories\Daemon\DaemonSystemRepository; -use App\Services\Helpers\SoftwareVersionService; -use App\Services\Nodes\NodeAutoDeployService; use App\Services\Nodes\NodeUpdateService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use Exception; +use App\Traits\Filament\NodeDetailTabs; use Filament\Actions\Action; use Filament\Actions\DeleteAction; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\Slider; -use Filament\Forms\Components\Slider\Enums\PipsMode; -use Filament\Forms\Components\TagsInput; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\ToggleButtons; -use Filament\Infolists\Components\CodeEntry; -use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Filament\Schemas\Components\Actions; -use Filament\Schemas\Components\Fieldset; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\StateCasts\BooleanStateCast; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Filament\Schemas\Components\Utilities\Get; -use Filament\Schemas\Components\Utilities\Set; -use Filament\Schemas\Components\View; use Filament\Schemas\Schema; -use Filament\Support\Enums\Alignment; -use Filament\Support\RawJs; use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\HtmlString; -use Phiki\Grammar\Grammar; class EditNode extends EditRecord { use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; + use NodeDetailTabs; protected static string $resource = NodeResource::class; @@ -81,739 +59,7 @@ public function form(Schema $schema): Schema /** @return Tab[] */ protected function getDefaultTabs(): array { - return [ - Tab::make('overview') - ->label(trans('admin/node.tabs.overview')) - ->icon(TablerIcon::ChartAreaLineFilled) - ->columns([ - 'default' => 4, - 'sm' => 2, - 'md' => 4, - 'lg' => 4, - ]) - ->schema([ - Fieldset::make() - ->label(trans('admin/node.node_info')) - ->columns(4) - ->columnSpanFull() - ->schema([ - TextEntry::make('wings_version') - ->label(trans('admin/node.wings_version')) - ->state(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? trans('admin/node.unknown')) . ' ' . trans('admin/node.latest', ['version' => $versionService->latestWingsVersion()])), - TextEntry::make('cpu_threads') - ->label(trans('admin/node.cpu_threads')) - ->state(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0), - TextEntry::make('architecture') - ->label(trans('admin/node.architecture')) - ->state(fn (Node $node) => $node->systemInformation()['architecture'] ?? trans('admin/node.unknown')), - TextEntry::make('kernel') - ->label(trans('admin/node.kernel')) - ->state(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? trans('admin/node.unknown')), - ]), - View::make('filament.components.node-cpu-chart') - ->columnSpan([ - 'default' => 4, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]), - View::make('filament.components.node-memory-chart') - ->columnSpan([ - 'default' => 4, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]), - View::make('filament.components.node-storage-chart') - ->columnSpanFull(), - ]), - Tab::make('basic_settings') - ->label(trans('admin/node.tabs.basic_settings')) - ->icon(TablerIcon::Server) - ->schema([ - TextInput::make('fqdn') - ->columnSpan(2) - ->required() - ->autofocus() - ->live(debounce: 1500) - ->rules(Node::getRulesForField('fqdn')) - ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) - ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') - ->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null) - ->validationMessages([ - 'prohibited' => trans('admin/node.dns_error'), - ]) - ->prohibited(function ($state, Get $get) { - if (!$state) { - return true; - } - - if (is_ip($state)) { - return false; - } - - $ip = $get('ip'); - - return !is_ip($ip); - }) - ->hintColor(function ($state, Get $get) { - if (!$state) { - return null; - } - - if (is_ip($state)) { - if (request()->isSecure()) { - return 'warning'; - } - } else { - $ip = $get('ip'); - - return is_ip($ip) ? 'success' : 'danger'; - } - - return null; - }) - ->hint(function ($state, Get $get) { - if (!$state) { - return null; - } - - if (is_ip($state)) { - if (request()->isSecure()) { - return trans('admin/node.ssl_ip'); - } - } else { - $ip = $get('ip'); - - return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid'); - } - - return null; - }) - ->afterStateUpdated(function (Set $set, ?string $state) { - $set('ip', null); - - if (!$state) { - return; - } - - [$subdomain] = str($state)->explode('.', 2); - if (!is_numeric($subdomain)) { - $set('name', $subdomain); - } - - if (!is_ip($state)) { - $ip = get_ip_from_hostname($state); - if (is_ip($ip)) { - $set('ip', $ip); - } else { - $set('ip', null); - } - } - }) - ->maxLength(255), - Hidden::make('ip') - ->saved(false), - TextInput::make('daemon_connect') - ->columnSpan(1) - ->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) - ->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) - ->minValue(1) - ->maxValue(65535) - ->default(8080) - ->required() - ->integer(), - TextInput::make('daemon_listen') - ->columnSpan(1) - ->label(trans('admin/node.listen_port')) - ->helperText(trans('admin/node.listen_port_help')) - ->minValue(1) - ->maxValue(65535) - ->default(8080) - ->required() - ->integer() - ->visible(fn (Get $get) => $get('connection') === 'https_proxy'), - TextInput::make('name') - ->label(trans('admin/node.display_name')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->required() - ->maxLength(100), - ToggleButtons::make('connection') - ->label(trans('admin/node.ssl')) - ->columnSpan(2) - ->inline() - ->helperText(function () { - if (request()->isSecure()) { - return trans('admin/node.panel_on_ssl'); - } - - return null; - }) - ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure()) - ->options([ - 'http' => 'HTTP', - 'https' => 'HTTPS (SSL)', - 'https_proxy' => 'HTTPS with (reverse) proxy', - ]) - ->colors([ - 'http' => 'warning', - 'https' => 'success', - 'https_proxy' => 'success', - ]) - ->icons([ - 'http' => TablerIcon::LockOpenOff, - 'https' => TablerIcon::Lock, - 'https_proxy' => TablerIcon::ShieldLock, - ]) - ->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https')) - ->live() - ->dehydrated(false) - ->afterStateUpdated(function ($state, Set $set) { - $set('scheme', $state === 'http' ? 'http' : 'https'); - $set('behind_proxy', $state === 'https_proxy'); - - $set('daemon_connect', $state === 'https_proxy' ? 443 : 8080); - $set('daemon_listen', 8080); - }), - Hidden::make('scheme') - ->default(fn () => request()->isSecure() ? 'https' : 'http'), - Hidden::make('behind_proxy') - ->default(false), - ]), - Tab::make('advanced_settings') - ->label(trans('admin/node.tabs.advanced_settings')) - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 4, - 'lg' => 6, - ]) - ->icon(TablerIcon::ServerCog) - ->schema([ - TextInput::make('id') - ->label(trans('admin/node.node_id')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->disabled(), - TextInput::make('uuid') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->label(trans('admin/node.node_uuid')) - ->hintCopy() - ->disabled(), - TagsInput::make('tags') - ->label(trans('admin/node.tags')) - ->placeholder('') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]), - TextInput::make('upload_size') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->label(trans('admin/node.upload_limit')) - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.upload_limit_help')) - ->numeric() - ->required() - ->minValue(1) - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), - TextInput::make('daemon_base') - ->label(trans('admin/node.daemon_base')) - ->placeholder('/var/lib/pelican/volumes') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->required() - ->rule('regex:/^([\/][\d\w.\-\/]+)$/'), - TextInput::make('daemon_sftp') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->label(trans('admin/node.sftp_port')) - ->minValue(1) - ->maxValue(65535) - ->default(2022) - ->required() - ->integer(), - TextInput::make('daemon_sftp_alias') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->label(trans('admin/node.sftp_alias')) - ->helperText(trans('admin/node.sftp_alias_help')), - ToggleButtons::make('public') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->label(trans('admin/node.use_for_deploy')) - ->inline() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.yes'), - 0 => trans('admin/node.no'), - ]) - ->colors([ - 1 => 'success', - 0 => 'danger', - ]), - ToggleButtons::make('maintenance_mode') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->label(trans('admin/node.maintenance_mode')) - ->inline() - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.maintenance_mode_help')) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.enabled'), - 0 => trans('admin/node.disabled'), - ]) - ->colors([ - 1 => 'danger', - 0 => 'success', - ]), - Grid::make() - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 3, - 'lg' => 6, - ]) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_mem') - ->dehydrated() - ->label(trans('admin/node.memory'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) - ->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('memory') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]), - TextInput::make('memory') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->label(trans('admin/node.memory_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(0), - TextInput::make('memory_overallocate') - ->dehydratedWhenHidden() - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->required() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - Grid::make() - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 3, - 'lg' => 6, - ]) - ->schema([ - ToggleButtons::make('unlimited_disk') - ->dehydrated() - ->label(trans('admin/node.disk'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) - ->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('disk') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]), - TextInput::make('disk') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/node.disk_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(0), - TextInput::make('disk_overallocate') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->required() - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - Grid::make() - ->columns(6) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_cpu') - ->dehydrated() - ->label(trans('admin/node.cpu'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) - ->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - TextInput::make('cpu') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/node.cpu_limit'))->inlineLabel() - ->suffix('%') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - TextInput::make('cpu_overallocate') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->columnSpan(2) - ->required() - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - ]), - Tab::make('config_file') - ->label(trans('admin/node.tabs.config_file')) - ->icon(TablerIcon::Code) - ->schema([ - TextEntry::make('instructions') - ->label(trans('admin/node.instructions')) - ->columnSpanFull() - ->state(new HtmlString(trans('admin/node.instructions_help'))), - CodeEntry::make('config') - ->label('/etc/pelican/config.yml') - ->grammar(Grammar::Yaml) - ->state(fn (Node $node) => $node->getYamlConfiguration()) - ->copyable() - ->disabled() - ->columnSpanFull(), - Grid::make() - ->columns() - ->columnSpanFull() - ->schema([ - Actions::make([ - Action::make('exclude_autoDeploy') - ->label(trans('admin/node.auto_deploy')) - ->color('primary') - ->modalHeading(trans('admin/node.auto_deploy')) - ->icon(TablerIcon::Rocket) - ->modalSubmitAction(false) - ->modalCancelAction(false) - ->modalFooterActionsAlignment(Alignment::Center) - ->schema([ - ToggleButtons::make('docker') - ->label(trans('admin/node.auto_label')) - ->live() - ->helperText(trans('admin/node.auto_question')) - ->inline() - ->default(false) - ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/node.standalone'), - 1 => trans('admin/node.docker'), - ]) - ->colors([ - 0 => 'primary', - 1 => 'success', - ]) - ->columnSpan(1), - Textarea::make('generatedToken') - ->label(trans('admin/node.auto_command')) - ->readOnly() - ->autosize() - ->hintCopy() - ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), - ]) - ->mountUsing(function (Schema $schema) { - $schema->fill(); - }), - ])->fullWidth(), - Actions::make([ - Action::make('exclude_resetKey') - ->label(trans('admin/node.reset_token')) - ->color('danger') - ->requiresConfirmation() - ->modalHeading(trans('admin/node.reset_token')) - ->modalDescription(trans('admin/node.reset_help')) - ->action(function (Node $node) { - try { - $this->nodeUpdateService->handle($node, [], true); - } catch (Exception) { - Notification::make() - ->title(trans('admin/node.error_connecting', ['node' => $node->name])) - ->body(trans('admin/node.error_connecting_description')) - ->color('warning') - ->icon(TablerIcon::Database) - ->warning() - ->send(); - - } - Notification::make()->success()->title(trans('admin/node.token_reset'))->send(); - $this->fillForm(); - }), - ])->fullWidth(), - ]), - ]), - Tab::make('diagnostics') - ->label(trans('admin/node.tabs.diagnostics')) - ->icon(TablerIcon::HeartSearch) - ->schema([ - Section::make('diag') - ->heading(trans('admin/node.tabs.diagnostics')) - ->columnSpanFull() - ->columns(4) - ->disabled(fn (Get $get) => $get('pulled')) - ->headerActions([ - Action::make('pull') - ->tooltip(trans('admin/node.diagnostics.pull')) - ->icon(TablerIcon::CloudDownload) - ->hidden(fn (Get $get) => $get('pulled')) - ->action(function (Get $get, Set $set, Node $node) { - $includeEndpoints = $get('include_endpoints') ?? true; - $includeLogs = $get('include_logs') ?? true; - $logLines = $get('log_lines') ?? 200; - - try { - $response = $this->daemonSystemRepository->setNode($node)->getDiagnostics($logLines, $includeEndpoints, $includeLogs); - - if ($response->status() === 404) { - Notification::make() - ->title(trans('admin/node.diagnostics.404')) - ->warning() - ->send(); - - return; - } - - $set('pulled', true); - $set('uploaded', false); - $set('log', $response->body()); - - Notification::make() - ->title(trans('admin/node.diagnostics.logs_pulled')) - ->success() - ->send(); - } catch (ConnectionException $e) { - Notification::make() - ->title(trans('admin/node.error_connecting', ['node' => $node->name])) - ->body($e->getMessage()) - ->danger() - ->send(); - - } - }), - Action::make('upload') - ->tooltip(trans('admin/node.diagnostics.upload')) - ->visible(fn (Get $get) => $get('pulled') ?? false) - ->icon(TablerIcon::CloudUpload) - ->action(function (Get $get, Set $set) { - try { - $response = Http::asMultipart() - ->attach('c', $get('log')) - ->attach('e', '14d') - ->post('https://logs.pelican.dev'); - - if ($response->failed()) { - Notification::make() - ->title(trans('admin/node.diagnostics.upload_failed')) - ->body(fn () => $response->status() . ' - ' . $response->body()) - ->danger() - ->send(); - - return; - } - - $data = $response->json(); - $url = $data['url']; - - Notification::make() - ->title(trans('admin/node.diagnostics.logs_uploaded')) - ->body("{$url}") - ->success() - ->actions([ - Action::make('exclude_viewLogs') - ->label(trans('admin/node.diagnostics.view_logs')) - ->url($url) - ->openUrlInNewTab(true), - ]) - ->persistent() - ->send(); - $set('log', $url); - $set('pulled', false); - $set('uploaded', true); - - } catch (Exception $e) { - Notification::make() - ->title(trans('admin/node.diagnostics.upload_failed')) - ->body($e->getMessage()) - ->danger() - ->send(); - } - }), - Action::make('clear') - ->tooltip(trans('admin/node.diagnostics.clear')) - ->visible(fn (Get $get) => $get('pulled') ?? false) - ->icon(TablerIcon::Trash)->color('danger') - ->action(function (Get $get, Set $set) { - $set('pulled', false); - $set('uploaded', false); - $set('log', null); - $this->refresh(); - } - ), - ]) - ->schema([ - ToggleButtons::make('include_endpoints') - ->hintIcon(TablerIcon::QuestionMark)->inline() - ->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint')) - ->formatStateUsing(fn () => 1) - ->boolean(), - ToggleButtons::make('include_logs') - ->live() - ->hintIcon(TablerIcon::QuestionMark)->inline() - ->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint')) - ->formatStateUsing(fn () => 1) - ->boolean(), - Slider::make('log_lines') - ->columnSpan(2) - ->hiddenLabel() - ->live() - ->tooltips(RawJs::make(<<<'JS' - `${$value} lines` - JS)) - ->visible(fn (Get $get) => $get('include_logs')) - ->range(minValue: 100, maxValue: 500) - ->pips(PipsMode::Steps, density: 10) - ->step(50) - ->formatStateUsing(fn () => 200) - ->fillTrack(), - Hidden::make('pulled'), - Hidden::make('uploaded'), - ]), - Textarea::make('log') - ->hiddenLabel() - ->columnSpanFull() - ->rows(35) - ->visible(fn (Get $get) => ($get('pulled') ?? false) || ($get('uploaded') ?? false)), - ]), - ]; - } - - protected function mutateFormDataBeforeFill(array $data): array - { - $node = Node::findOrFail($data['id']); - - if (!is_ip($node->fqdn)) { - $ip = get_ip_from_hostname($node->fqdn); - if ($ip) { - $data['dns'] = true; - $data['ip'] = $ip; - } else { - $data['dns'] = false; - } - } - - return $data; + return $this->detailTabs(); } protected function getFormActions(): array diff --git a/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php new file mode 100644 index 0000000000..5f923b0278 --- /dev/null +++ b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php @@ -0,0 +1,68 @@ +daemonSystemRepository = $daemonSystemRepository; + $this->nodeUpdateService = $nodeUpdateService; + } + + public function form(Schema $schema): Schema + { + return $schema->components([ + Tabs::make('Tabs') + ->columns([ + 'default' => 2, + 'sm' => 3, + 'md' => 3, + 'lg' => 4, + ]) + ->persistTabInQueryString() + ->columnSpanFull() + ->tabs($this->getTabs()), + ]); + } + + /** @return Tab[] */ + protected function getDefaultTabs(): array + { + return $this->detailTabs(); + } + + /** @return array */ + protected function getDefaultHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } +} diff --git a/app/Traits/Filament/NodeDetailTabs.php b/app/Traits/Filament/NodeDetailTabs.php new file mode 100644 index 0000000000..3649f39d2b --- /dev/null +++ b/app/Traits/Filament/NodeDetailTabs.php @@ -0,0 +1,786 @@ +label(trans('admin/node.tabs.overview')) + ->icon(TablerIcon::ChartAreaLineFilled) + ->columns([ + 'default' => 4, + 'sm' => 2, + 'md' => 4, + 'lg' => 4, + ]) + ->schema([ + Fieldset::make() + ->label(trans('admin/node.node_info')) + ->columns(4) + ->columnSpanFull() + ->schema([ + TextEntry::make('wings_version') + ->label(trans('admin/node.wings_version')) + ->state(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? trans('admin/node.unknown')) . ' ' . trans('admin/node.latest', ['version' => $versionService->latestWingsVersion()])), + TextEntry::make('cpu_threads') + ->label(trans('admin/node.cpu_threads')) + ->state(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0), + TextEntry::make('architecture') + ->label(trans('admin/node.architecture')) + ->state(fn (Node $node) => $node->systemInformation()['architecture'] ?? trans('admin/node.unknown')), + TextEntry::make('kernel') + ->label(trans('admin/node.kernel')) + ->state(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? trans('admin/node.unknown')), + ]), + View::make('filament.components.node-cpu-chart') + ->columnSpan([ + 'default' => 4, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]), + View::make('filament.components.node-memory-chart') + ->columnSpan([ + 'default' => 4, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]), + View::make('filament.components.node-storage-chart') + ->columnSpanFull(), + ]), + Tab::make('basic_settings') + ->label(trans('admin/node.tabs.basic_settings')) + ->icon(TablerIcon::Server) + ->schema([ + TextInput::make('fqdn') + ->columnSpan(2) + ->required() + ->autofocus() + ->live(debounce: 1500) + ->rules(Node::getRulesForField('fqdn')) + ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) + ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') + ->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null) + ->validationMessages([ + 'prohibited' => trans('admin/node.dns_error'), + ]) + ->prohibited(function ($state, Get $get) { + if (!$state) { + return true; + } + + if (is_ip($state)) { + return false; + } + + $ip = $get('ip'); + + return !is_ip($ip); + }) + ->hintColor(function ($state, Get $get) { + if (!$state) { + return null; + } + + if (is_ip($state)) { + if (request()->isSecure()) { + return 'warning'; + } + } else { + $ip = $get('ip'); + + return is_ip($ip) ? 'success' : 'danger'; + } + + return null; + }) + ->hint(function ($state, Get $get) { + if (!$state) { + return null; + } + + if (is_ip($state)) { + if (request()->isSecure()) { + return trans('admin/node.ssl_ip'); + } + } else { + $ip = $get('ip'); + + return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid'); + } + + return null; + }) + ->afterStateUpdated(function (Set $set, ?string $state) { + $set('ip', null); + + if (!$state) { + return; + } + + [$subdomain] = str($state)->explode('.', 2); + if (!is_numeric($subdomain)) { + $set('name', $subdomain); + } + + if (!is_ip($state)) { + $ip = get_ip_from_hostname($state); + if (is_ip($ip)) { + $set('ip', $ip); + } else { + $set('ip', null); + } + } + }) + ->maxLength(255), + Hidden::make('ip') + ->saved(false), + TextInput::make('daemon_connect') + ->columnSpan(1) + ->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) + ->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) + ->minValue(1) + ->maxValue(65535) + ->default(8080) + ->required() + ->integer(), + TextInput::make('daemon_listen') + ->columnSpan(1) + ->label(trans('admin/node.listen_port')) + ->helperText(trans('admin/node.listen_port_help')) + ->minValue(1) + ->maxValue(65535) + ->default(8080) + ->required() + ->integer() + ->visible(fn (Get $get) => $get('connection') === 'https_proxy'), + TextInput::make('name') + ->label(trans('admin/node.display_name')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->required() + ->maxLength(100), + ToggleButtons::make('connection') + ->label(trans('admin/node.ssl')) + ->columnSpan(2) + ->inline() + ->helperText(function () { + if (request()->isSecure()) { + return trans('admin/node.panel_on_ssl'); + } + + return null; + }) + ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure()) + ->options([ + 'http' => 'HTTP', + 'https' => 'HTTPS (SSL)', + 'https_proxy' => 'HTTPS with (reverse) proxy', + ]) + ->colors([ + 'http' => 'warning', + 'https' => 'success', + 'https_proxy' => 'success', + ]) + ->icons([ + 'http' => TablerIcon::LockOpenOff, + 'https' => TablerIcon::Lock, + 'https_proxy' => TablerIcon::ShieldLock, + ]) + ->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https')) + ->live() + ->dehydrated(false) + ->afterStateUpdated(function ($state, Set $set) { + $set('scheme', $state === 'http' ? 'http' : 'https'); + $set('behind_proxy', $state === 'https_proxy'); + + $set('daemon_connect', $state === 'https_proxy' ? 443 : 8080); + $set('daemon_listen', 8080); + }), + Hidden::make('scheme') + ->default(fn () => request()->isSecure() ? 'https' : 'http'), + Hidden::make('behind_proxy') + ->default(false), + ]), + Tab::make('advanced_settings') + ->label(trans('admin/node.tabs.advanced_settings')) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 4, + 'lg' => 6, + ]) + ->icon(TablerIcon::ServerCog) + ->schema([ + TextInput::make('id') + ->label(trans('admin/node.node_id')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->disabled(), + TextInput::make('uuid') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->label(trans('admin/node.node_uuid')) + ->hintCopy() + ->disabled(), + TagsInput::make('tags') + ->label(trans('admin/node.tags')) + ->placeholder('') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]), + TextInput::make('upload_size') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->label(trans('admin/node.upload_limit')) + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.upload_limit_help')) + ->numeric() + ->required() + ->minValue(1) + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), + TextInput::make('daemon_base') + ->label(trans('admin/node.daemon_base')) + ->placeholder('/var/lib/pelican/volumes') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->required() + ->rule('regex:/^([\/][\d\w.\-\/]+)$/'), + TextInput::make('daemon_sftp') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->label(trans('admin/node.sftp_port')) + ->minValue(1) + ->maxValue(65535) + ->default(2022) + ->required() + ->integer(), + TextInput::make('daemon_sftp_alias') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->label(trans('admin/node.sftp_alias')) + ->helperText(trans('admin/node.sftp_alias_help')), + ToggleButtons::make('public') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->label(trans('admin/node.use_for_deploy')) + ->inline() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.yes'), + 0 => trans('admin/node.no'), + ]) + ->colors([ + 1 => 'success', + 0 => 'danger', + ]), + ToggleButtons::make('maintenance_mode') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->label(trans('admin/node.maintenance_mode')) + ->inline() + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.maintenance_mode_help')) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.enabled'), + 0 => trans('admin/node.disabled'), + ]) + ->colors([ + 1 => 'danger', + 0 => 'success', + ]), + Grid::make() + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_mem') + ->dehydrated() + ->label(trans('admin/node.memory'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) + ->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('memory') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), + TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->label(trans('admin/node.memory_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(0), + TextInput::make('memory_overallocate') + ->dehydratedWhenHidden() + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->required() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + Grid::make() + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) + ->schema([ + ToggleButtons::make('unlimited_disk') + ->dehydrated() + ->label(trans('admin/node.disk'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) + ->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('disk') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), + TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/node.disk_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(0), + TextInput::make('disk_overallocate') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->required() + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + Grid::make() + ->columns(6) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_cpu') + ->dehydrated() + ->label(trans('admin/node.cpu'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) + ->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/node.cpu_limit'))->inlineLabel() + ->suffix('%') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + TextInput::make('cpu_overallocate') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->columnSpan(2) + ->required() + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + ]), + Tab::make('config_file') + ->label(trans('admin/node.tabs.config_file')) + ->icon(TablerIcon::Code) + ->schema([ + TextEntry::make('instructions') + ->label(trans('admin/node.instructions')) + ->columnSpanFull() + ->state(new HtmlString(trans('admin/node.instructions_help'))), + CodeEntry::make('config') + ->label('/etc/pelican/config.yml') + ->grammar(Grammar::Yaml) + ->state(fn (Node $node) => $node->getYamlConfiguration()) + ->copyable() + ->disabled() + ->columnSpanFull(), + Grid::make() + ->columns() + ->columnSpanFull() + ->schema([ + Actions::make([ + Action::make('exclude_autoDeploy') + ->label(trans('admin/node.auto_deploy')) + ->color('primary') + ->modalHeading(trans('admin/node.auto_deploy')) + ->icon(TablerIcon::Rocket) + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalFooterActionsAlignment(Alignment::Center) + ->schema([ + ToggleButtons::make('docker') + ->label(trans('admin/node.auto_label')) + ->live() + ->helperText(trans('admin/node.auto_question')) + ->inline() + ->default(false) + ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/node.standalone'), + 1 => trans('admin/node.docker'), + ]) + ->colors([ + 0 => 'primary', + 1 => 'success', + ]) + ->columnSpan(1), + Textarea::make('generatedToken') + ->label(trans('admin/node.auto_command')) + ->readOnly() + ->autosize() + ->hintCopy() + ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), + ]) + ->mountUsing(function (Schema $schema) { + $schema->fill(); + }) + ->hidden(fn (string $operation) => $operation === 'view'), + ])->fullWidth(), + Actions::make([ + Action::make('exclude_resetKey') + ->label(trans('admin/node.reset_token')) + ->color('danger') + ->requiresConfirmation() + ->modalHeading(trans('admin/node.reset_token')) + ->modalDescription(trans('admin/node.reset_help')) + ->action(function (Node $node) { + try { + $this->nodeUpdateService->handle($node, [], true); + } catch (Exception) { + Notification::make() + ->title(trans('admin/node.error_connecting', ['node' => $node->name])) + ->body(trans('admin/node.error_connecting_description')) + ->color('warning') + ->icon(TablerIcon::Database) + ->warning() + ->send(); + + } + Notification::make()->success()->title(trans('admin/node.token_reset'))->send(); + $this->fillForm(); + }) + ->hidden(fn (string $operation) => $operation === 'view'), + ])->fullWidth(), + ]), + ]), + Tab::make('diagnostics') + ->label(trans('admin/node.tabs.diagnostics')) + ->icon(TablerIcon::HeartSearch) + ->hidden(fn (string $operation) => $operation === 'view') + ->schema([ + Section::make('diag') + ->heading(trans('admin/node.tabs.diagnostics')) + ->columnSpanFull() + ->columns(4) + ->disabled(fn (Get $get) => $get('pulled')) + ->headerActions([ + Action::make('pull') + ->tooltip(trans('admin/node.diagnostics.pull')) + ->icon(TablerIcon::CloudDownload) + ->hidden(fn (Get $get) => $get('pulled')) + ->action(function (Get $get, Set $set, Node $node) { + $includeEndpoints = $get('include_endpoints') ?? true; + $includeLogs = $get('include_logs') ?? true; + $logLines = $get('log_lines') ?? 200; + + try { + $response = $this->daemonSystemRepository->setNode($node)->getDiagnostics($logLines, $includeEndpoints, $includeLogs); + + if ($response->status() === 404) { + Notification::make() + ->title(trans('admin/node.diagnostics.404')) + ->warning() + ->send(); + + return; + } + + $set('pulled', true); + $set('uploaded', false); + $set('log', $response->body()); + + Notification::make() + ->title(trans('admin/node.diagnostics.logs_pulled')) + ->success() + ->send(); + } catch (ConnectionException $e) { + Notification::make() + ->title(trans('admin/node.error_connecting', ['node' => $node->name])) + ->body($e->getMessage()) + ->danger() + ->send(); + + } + }), + Action::make('upload') + ->tooltip(trans('admin/node.diagnostics.upload')) + ->visible(fn (Get $get) => $get('pulled') ?? false) + ->icon(TablerIcon::CloudUpload) + ->action(function (Get $get, Set $set) { + try { + $response = Http::asMultipart() + ->attach('c', $get('log')) + ->attach('e', '14d') + ->post('https://logs.pelican.dev'); + + if ($response->failed()) { + Notification::make() + ->title(trans('admin/node.diagnostics.upload_failed')) + ->body(fn () => $response->status() . ' - ' . $response->body()) + ->danger() + ->send(); + + return; + } + + $data = $response->json(); + $url = $data['url']; + + Notification::make() + ->title(trans('admin/node.diagnostics.logs_uploaded')) + ->body("{$url}") + ->success() + ->actions([ + Action::make('exclude_viewLogs') + ->label(trans('admin/node.diagnostics.view_logs')) + ->url($url) + ->openUrlInNewTab(true), + ]) + ->persistent() + ->send(); + $set('log', $url); + $set('pulled', false); + $set('uploaded', true); + + } catch (Exception $e) { + Notification::make() + ->title(trans('admin/node.diagnostics.upload_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('clear') + ->tooltip(trans('admin/node.diagnostics.clear')) + ->visible(fn (Get $get) => $get('pulled') ?? false) + ->icon(TablerIcon::Trash)->color('danger') + ->action(function (Get $get, Set $set) { + $set('pulled', false); + $set('uploaded', false); + $set('log', null); + $this->refresh(); + } + ), + ]) + ->schema([ + ToggleButtons::make('include_endpoints') + ->hintIcon(TablerIcon::QuestionMark)->inline() + ->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint')) + ->formatStateUsing(fn () => 1) + ->boolean(), + ToggleButtons::make('include_logs') + ->live() + ->hintIcon(TablerIcon::QuestionMark)->inline() + ->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint')) + ->formatStateUsing(fn () => 1) + ->boolean(), + Slider::make('log_lines') + ->columnSpan(2) + ->hiddenLabel() + ->live() + ->tooltips(RawJs::make(<<<'JS' + `${$value} lines` + JS)) + ->visible(fn (Get $get) => $get('include_logs')) + ->range(minValue: 100, maxValue: 500) + ->pips(PipsMode::Steps, density: 10) + ->step(50) + ->formatStateUsing(fn () => 200) + ->fillTrack(), + Hidden::make('pulled'), + Hidden::make('uploaded'), + ]), + Textarea::make('log') + ->hiddenLabel() + ->columnSpanFull() + ->rows(35) + ->visible(fn (Get $get) => ($get('pulled') ?? false) || ($get('uploaded') ?? false)), + ]), + ]; + } + + protected function mutateFormDataBeforeFill(array $data): array + { + $node = Node::findOrFail($data['id']); + + if (!is_ip($node->fqdn)) { + $ip = get_ip_from_hostname($node->fqdn); + if ($ip) { + $data['dns'] = true; + $data['ip'] = $ip; + } else { + $data['dns'] = false; + } + } + + return $data; + } +} From 67408bb65577e094d3f7db9c0e26f9194e44ed31 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 19:05:57 +0100 Subject: [PATCH 06/20] add view row action to node list table --- app/Filament/Admin/Resources/Nodes/Pages/ListNodes.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Filament/Admin/Resources/Nodes/Pages/ListNodes.php b/app/Filament/Admin/Resources/Nodes/Pages/ListNodes.php index afaf12a77e..5abc46fdef 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/ListNodes.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/ListNodes.php @@ -12,6 +12,7 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets; use Filament\Actions\CreateAction; use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; @@ -63,6 +64,9 @@ public function table(Table $table): Table ->sortable(), ]) ->recordActions([ + ViewAction::make() + ->tooltip(trans('filament-actions::view.single.label')) + ->hidden(fn (Node $record) => NodeResource::getEditAuthorizationResponse($record)->allowed()), EditAction::make(), ]) ->toolbarActions([ From 994ecec976df74035ddf5d182a04d1b8ecf29305 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 19:30:27 +0100 Subject: [PATCH 07/20] make node relation managers read-only on view --- .../AllocationsRelationManager.php | 16 +++++++++++----- .../RelationManagers/ServersRelationManager.php | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php b/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php index e08d6eb0f3..dcd67e49bc 100644 --- a/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Admin/Resources/Nodes/RelationManagers/AllocationsRelationManager.php @@ -64,10 +64,12 @@ public function table(Table $table): Table ->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''), TextInputColumn::make('ip_alias') ->searchable() - ->label(trans('admin/node.table.alias')), + ->label(trans('admin/node.table.alias')) + ->disabled(fn () => $this->isReadOnly()), TextInputColumn::make('notes') ->label(trans('admin/node.table.allocation_notes')) - ->placeholder(trans('admin/node.table.no_notes')), + ->placeholder(trans('admin/node.table.no_notes')) + ->disabled(fn () => $this->isReadOnly()), SelectColumn::make('ip') ->options(function (Allocation $allocation) { $ips = Allocation::where('port', $allocation->port)->pluck('ip'); @@ -81,14 +83,17 @@ public function table(Table $table): Table }) ->selectablePlaceholder(false) ->searchable() - ->label(trans('admin/node.table.ip')), + ->label(trans('admin/node.table.ip')) + ->disabled(fn () => $this->isReadOnly()), ]) ->toolbarActions([ DeleteBulkAction::make() - ->authorize(fn () => user()?->can('update', $this->getOwnerRecord())), + ->authorize(fn () => user()?->can('update', $this->getOwnerRecord())) + ->hidden(fn () => $this->isReadOnly()), Action::make('create new allocation') ->tooltip(trans('admin/node.create_allocation')) ->icon(TablerIcon::WorldPlus) + ->visible(fn () => !$this->isReadOnly()) ->schema(fn () => [ Select::make('allocation_ip') ->options(fn (Get $get) => collect($this->getOwnerRecord()->ipAddresses()) @@ -141,7 +146,8 @@ public function table(Table $table): Table ->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord(), $data)), UpdateNodeAllocations::make() ->nodeRecord($this->getOwnerRecord()) - ->authorize(fn () => user()?->can('update', $this->getOwnerRecord())), + ->authorize(fn () => user()?->can('update', $this->getOwnerRecord())) + ->hidden(fn () => $this->isReadOnly()), ]); } } diff --git a/app/Filament/Admin/Resources/Nodes/RelationManagers/ServersRelationManager.php b/app/Filament/Admin/Resources/Nodes/RelationManagers/ServersRelationManager.php index e6192cd446..e3078fd0bf 100644 --- a/app/Filament/Admin/Resources/Nodes/RelationManagers/ServersRelationManager.php +++ b/app/Filament/Admin/Resources/Nodes/RelationManagers/ServersRelationManager.php @@ -43,7 +43,7 @@ public function table(Table $table): Table ->sortable(), SelectColumn::make('allocation.id') ->label(trans('admin/node.primary_allocation')) - ->disabled(fn (Server $server) => $server->allocations->count() <= 1) + ->disabled(fn (Server $server) => $this->isReadOnly() || $server->allocations->count() <= 1) ->options(fn (Server $server) => $server->allocations->take(1)->mapWithKeys(fn ($allocation) => [$allocation->id => $allocation->address])) ->selectablePlaceholder(fn (Server $server) => $server->allocations->count() <= 1) ->placeholder(trans('admin/server.none')) From d30c1e84c1c5b0b6b4a6a6ae5a1f7ad25963292e Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 20:10:42 +0100 Subject: [PATCH 08/20] add missing column layout methods to node view page --- app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php index 5f923b0278..6eeabff798 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php @@ -65,4 +65,14 @@ protected function getDefaultHeaderActions(): array EditAction::make(), ]; } + + protected function getColumnSpan(): ?int + { + return null; + } + + protected function getColumnStart(): ?int + { + return null; + } } From a16125ed29a11b23ff8424a46dd68621aa464320 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 20:10:42 +0100 Subject: [PATCH 09/20] test the node view page --- tests/Filament/Admin/ViewNodeTest.php | 196 ++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/Filament/Admin/ViewNodeTest.php diff --git a/tests/Filament/Admin/ViewNodeTest.php b/tests/Filament/Admin/ViewNodeTest.php new file mode 100644 index 0000000000..d16e0290e1 --- /dev/null +++ b/tests/Filament/Admin/ViewNodeTest.php @@ -0,0 +1,196 @@ +create(['name' => $name, 'guard_name' => 'web']); + + foreach ($abilities as $ability) { + $role->givePermissionTo(Permission::findOrCreate($ability, 'web')); + } + + return $role; +} + +/** @return array{0: Node, 1: Allocation, 2: Server} */ +function nodeWithMultiAllocationServer(): array +{ + $node = Node::factory()->create(); + $server = Server::factory()->withNode($node)->create(); + $allocations = Allocation::factory()->count(2)->create([ + 'node_id' => $node->getKey(), + 'server_id' => $server->getKey(), + ]); + $server->update(['allocation_id' => $allocations->first()->getKey()]); + + return [$node, $allocations->first(), $server->refresh()]; +} + +function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $server): void +{ + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $alias = $allocation->ip_alias; + $notes = $allocation->notes; + $primary = $server->allocation_id; + $otherAllocationId = $server->allocations->last()->getKey(); + + livewire(AllocationsRelationManager::class, [ + 'ownerRecord' => $node, + 'pageClass' => ViewNode::class, + ]) + ->assertTableActionHidden('create new allocation') + ->assertTableActionHidden(UpdateNodeAllocations::class) + ->assertTableBulkActionHidden(DeleteBulkAction::class) + ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias') + ->call('updateTableColumnState', 'notes', (string) $allocation->getKey(), 'hacked-notes'); + + livewire(ServersRelationManager::class, [ + 'ownerRecord' => $node, + 'pageClass' => ViewNode::class, + ]) + ->call('updateTableColumnState', 'allocation.id', (string) $server->getKey(), (string) $otherAllocationId); + + expect($allocation->refresh()->ip_alias)->toBe($alias) + ->and($allocation->notes)->toBe($notes) + ->and($server->refresh()->allocation_id)->toBe($primary); +} + +it('lets a user with view permission open the view page', function () { + $node = Node::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + $this->actingAs($user); + livewire(ViewNode::class, ['record' => $node->getKey()]) + ->assertSuccessful(); +}); + +it('forbids the view page without view permission', function () { + $node = Node::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(nodeRole('Egg Viewer', [ + RolePermissionModels::Egg->view(), + ])); + + $this->actingAs($user); + livewire(ViewNode::class, ['record' => $node->getKey()]) + ->assertForbidden(); +}); + +it('forbids the edit page for a view-only user', function () { + $node = Node::factory()->create(); + + [$user] = generateTestAccount([]); + $user->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + $this->actingAs($user); + livewire(EditNode::class, ['record' => $node->getKey()]) + ->assertForbidden(); +}); + +it('hides the reset-token action on the view page but keeps it on edit', function () { + $node = Node::factory()->create(); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(nodeRole('Node Editor', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + RolePermissionModels::Node->update(), + ])); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + $this->actingAs($editor); + livewire(EditNode::class, ['record' => $node->getKey()]) + ->assertActionVisible(TestAction::make('exclude_resetKey')->schemaComponent(true)); + + $this->actingAs($viewer); + livewire(ViewNode::class, ['record' => $node->getKey()]) + ->assertActionDoesNotExist(TestAction::make('exclude_resetKey')->schemaComponent(true)); +}); + +it('shows the view row action only when the user cannot edit', function () { + $node = Node::factory()->create(); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(nodeRole('Node Editor', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + RolePermissionModels::Node->update(), + ])); + + // table action urls resolve against the current panel; the default is 'app', not 'admin' + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $this->actingAs($viewer); + livewire(ListNodes::class) + ->assertTableActionVisible('view', $node); + + $this->actingAs($editor); + livewire(ListNodes::class) + ->assertTableActionHidden('view', $node); +}); + +it('keeps the node relation managers read-only on the view page for a view-only user', function () { + [$node, $allocation, $server] = nodeWithMultiAllocationServer(); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + $this->actingAs($viewer); + assertNodeViewIsReadOnly($node, $allocation, $server); +}); + +it('keeps the node relation managers read-only on the view page even for an update-capable user', function () { + [$node, $allocation, $server] = nodeWithMultiAllocationServer(); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(nodeRole('Node Editor', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + RolePermissionModels::Node->update(), + ])); + + $this->actingAs($editor); + assertNodeViewIsReadOnly($node, $allocation, $server); +}); From a22d0eae9b8aa4549dd48c14f8303bfb87e3235b Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Fri, 12 Jun 2026 20:56:50 +0100 Subject: [PATCH 10/20] hide the config tab on the node view page --- app/Traits/Filament/NodeDetailTabs.php | 1 + tests/Filament/Admin/ViewNodeTest.php | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/Traits/Filament/NodeDetailTabs.php b/app/Traits/Filament/NodeDetailTabs.php index 3649f39d2b..2440fbc1fd 100644 --- a/app/Traits/Filament/NodeDetailTabs.php +++ b/app/Traits/Filament/NodeDetailTabs.php @@ -539,6 +539,7 @@ protected function detailTabs(): array Tab::make('config_file') ->label(trans('admin/node.tabs.config_file')) ->icon(TablerIcon::Code) + ->hidden(fn (string $operation) => $operation === 'view') ->schema([ TextEntry::make('instructions') ->label(trans('admin/node.instructions')) diff --git a/tests/Filament/Admin/ViewNodeTest.php b/tests/Filament/Admin/ViewNodeTest.php index d16e0290e1..4db994e7b3 100644 --- a/tests/Filament/Admin/ViewNodeTest.php +++ b/tests/Filament/Admin/ViewNodeTest.php @@ -30,7 +30,7 @@ function nodeRole(string $name, array $abilities): Role return $role; } -/** @return array{0: Node, 1: Allocation, 2: Server} */ +/** @return array{Node, Allocation, Server} */ function nodeWithMultiAllocationServer(): array { $node = Node::factory()->create(); @@ -39,7 +39,7 @@ function nodeWithMultiAllocationServer(): array 'node_id' => $node->getKey(), 'server_id' => $server->getKey(), ]); - $server->update(['allocation_id' => $allocations->first()->getKey()]); + $server->update(['allocation_id' => $allocations->last()->getKey()]); return [$node, $allocations->first(), $server->refresh()]; } @@ -51,7 +51,9 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se $alias = $allocation->ip_alias; $notes = $allocation->notes; $primary = $server->allocation_id; - $otherAllocationId = $server->allocations->last()->getKey(); + // the primary column only offers the first allocation (take(1)); target that valid option while a + // different allocation is currently primary, so only the isReadOnly gate can stop the switch + $switchTarget = (string) $server->allocations->take(1)->first()->getKey(); livewire(AllocationsRelationManager::class, [ 'ownerRecord' => $node, @@ -67,7 +69,7 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se 'ownerRecord' => $node, 'pageClass' => ViewNode::class, ]) - ->call('updateTableColumnState', 'allocation.id', (string) $server->getKey(), (string) $otherAllocationId); + ->call('updateTableColumnState', 'allocation.id', (string) $server->getKey(), $switchTarget); expect($allocation->refresh()->ip_alias)->toBe($alias) ->and($allocation->notes)->toBe($notes) @@ -140,6 +142,22 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se ->assertActionDoesNotExist(TestAction::make('exclude_resetKey')->schemaComponent(true)); }); +it('does not expose the wings daemon token on the view page', function () { + $node = Node::factory()->create(); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(nodeRole('Node Viewer', [ + RolePermissionModels::Node->viewAny(), + RolePermissionModels::Node->view(), + ])); + + // the config_file tab renders the wings config yaml, which embeds daemon_token; it must be gone on view + $this->actingAs($viewer); + livewire(ViewNode::class, ['record' => $node->getKey()]) + ->assertDontSee($node->daemon_token) + ->assertDontSee($node->daemon_token_id); +}); + it('shows the view row action only when the user cannot edit', function () { $node = Node::factory()->create(); From 1e2fded9ae3d023795088fa99ff51a02a57f28e1 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 14:36:11 +0100 Subject: [PATCH 11/20] add view page for server admin resource --- .../Resources/Servers/Pages/EditServer.php | 906 +---------------- .../Resources/Servers/Pages/ViewServer.php | 67 ++ .../Resources/Servers/ServerResource.php | 2 + app/Traits/Filament/ServerDetailTabs.php | 953 ++++++++++++++++++ 4 files changed, 1025 insertions(+), 903 deletions(-) create mode 100644 app/Filament/Admin/Resources/Servers/Pages/ViewServer.php create mode 100644 app/Traits/Filament/ServerDetailTabs.php diff --git a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php index fe92b12323..6b2e3fb276 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php @@ -2,65 +2,24 @@ namespace App\Filament\Admin\Resources\Servers\Pages; -use App\Enums\SuspendAction; use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Servers\ServerResource; -use App\Filament\Components\Actions\DeleteIcon; -use App\Filament\Components\Actions\PreviewStartupAction; -use App\Filament\Components\Actions\UploadIcon; -use App\Filament\Components\Forms\Fields\MonacoEditor; -use App\Filament\Components\Forms\Fields\StartupVariable; -use App\Filament\Components\StateCasts\ServerConditionStateCast; use App\Filament\Server\Pages\Console; -use App\Models\Allocation; -use App\Models\Backup; -use App\Models\Egg; use App\Models\Server; -use App\Models\User; use App\Repositories\Daemon\DaemonServerRepository; -use App\Services\Eggs\EggChangerService; -use App\Services\Servers\RandomWordService; -use App\Services\Servers\ReinstallServerService; use App\Services\Servers\ServerDeletionService; -use App\Services\Servers\SuspensionService; -use App\Services\Servers\ToggleInstallService; -use App\Services\Servers\TransferServerService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use Exception; +use App\Traits\Filament\ServerDetailTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Forms\Components\CheckboxList; -use Filament\Forms\Components\Hidden; -use Filament\Forms\Components\KeyValue; -use Filament\Forms\Components\Repeater; -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TagsInput; -use Filament\Forms\Components\Textarea; -use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; -use Filament\Forms\Components\ToggleButtons; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; -use Filament\Schemas\Components\Actions; -use Filament\Schemas\Components\Component; -use Filament\Schemas\Components\Fieldset; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Image; -use Filament\Schemas\Components\StateCasts\BooleanStateCast; use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs\Tab; -use Filament\Schemas\Components\Text; -use Filament\Schemas\Components\Utilities\Get; -use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; -use Filament\Support\Enums\Alignment; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Arr; -use Illuminate\Support\HtmlString; -use LogicException; use Random\RandomException; class EditServer extends EditRecord @@ -68,6 +27,7 @@ class EditServer extends EditRecord use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; + use ServerDetailTabs; protected static string $resource = ServerResource::class; @@ -102,867 +62,7 @@ public function form(Schema $schema): Schema */ protected function getDefaultTabs(): array { - return [ - Tab::make('information') - ->label(trans('admin/server.tabs.information')) - ->icon(TablerIcon::InfoCircle) - ->schema([ - Grid::make() - ->columnStart(1) - ->schema([ - Image::make('', 'icon') - ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) - ->url(fn ($record) => $record->icon ?: $record->egg->icon) - ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) - ->imageSize(150) - ->columnSpanFull() - ->alignJustify(), - UploadIcon::make(), - DeleteIcon::make() - ->iconStoragePath(Server::getIconStoragePath()), - ]), - Grid::make() - ->columns(3) - ->columnStart(2) - ->columnSpan([ - 'default' => 2, - 'sm' => 2, - 'md' => 3, - 'lg' => 5, - ]) - ->schema([ - TextInput::make('name') - ->prefixIcon(TablerIcon::Server) - ->label(trans('admin/server.name')) - ->suffixAction(Action::make('hint_random') - ->tooltip('Random') - ->icon('tabler-dice-' . random_int(1, 6)) - ->action(function (Set $set, Get $get) { - $egg = Egg::find($get('egg_id')); - $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; - - $word = (new RandomWordService())->word(); - - $set('name', $prefix . $word); - })) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->required() - ->maxLength(255), - Select::make('owner_id') - ->prefixIcon(TablerIcon::User) - ->label(trans('admin/server.owner')) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->relationship('user', 'username') - ->searchable(['username', 'email']) - ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)") - ->preload() - ->required(), - ToggleButtons::make('condition') - ->label(trans('admin/server.server_status')) - ->formatStateUsing(fn (Server $server) => $server->condition) - ->options(fn ($state) => [$state->value => $state->getLabel()]) - ->colors(fn ($state) => [$state->value => $state->getColor()]) - ->icons(fn ($state) => [$state->value => $state->getIcon()]) - ->stateCast(new ServerConditionStateCast()) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) - ->hintAction( - Action::make('view_install_log') - ->label(trans('admin/server.view_install_log')) - //->visible(fn (Server $server) => $server->isFailedInstall()) - ->modalHeading('') - ->modalSubmitAction(false) - ->modalFooterActionsAlignment(Alignment::Right) - ->modalCancelActionLabel(trans('filament::components/modal.actions.close.label')) - ->schema([ - MonacoEditor::make('logs') - ->hiddenLabel() - ->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) { - try { - $logs = $serverRepository->setServer($server)->getInstallLogs(); - - return convert_to_utf8($logs); - } catch (ConnectionException) { - Notification::make() - ->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->body(trans('admin/server.notifications.log_failed')) - ->color('warning') - ->warning() - ->send(); - } catch (Exception) { - return ''; - } - - return ''; - }), - ]) - ), - ]), - Textarea::make('description') - ->label(trans('admin/server.description')) - ->columnSpanFull(), - TextInput::make('uuid') - ->label(trans('admin/server.uuid')) - ->copyable() - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->readOnly() - ->dehydrated(false), - TextInput::make('uuid_short') - ->label(trans('admin/server.short_uuid')) - ->copyable() - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->readOnly() - ->dehydrated(false), - TextInput::make('external_id') - ->label(trans('admin/server.external_id')) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->unique() - ->maxLength(255), - Select::make('node_id') - ->label(trans('admin/server.node')) - ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id'))) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->disabled(), - ]), - Tab::make('environment_configuration') - ->label(trans('admin/server.tabs.environment_configuration')) - ->icon(TablerIcon::BrandDocker) - ->schema([ - Fieldset::make(trans('admin/server.resource_limits')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_cpu') - ->dehydrated() - ->label(trans('admin/server.cpu'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) - ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('cpu') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/server.cpu_limit'))->inlineLabel() - ->suffix('%') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.cpu_helper')) - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_mem') - ->dehydrated() - ->label(trans('admin/server.memory'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) - ->formatStateUsing(fn (Get $get) => $get('memory') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('memory') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->label(trans('admin/server.memory_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.memory_helper')) - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_disk') - ->dehydrated() - ->label(trans('admin/server.disk'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) - ->formatStateUsing(fn (Get $get) => $get('disk') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('disk') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/server.disk_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - ]), - - Fieldset::make(trans('admin/server.advanced_limits')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('cpu_pinning') - ->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline() - ->default(0) - ->afterStateUpdated(fn (Set $set) => $set('threads', [])) - ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.disabled'), - 1 => trans('admin/server.enabled'), - ]) - ->colors([ - 0 => 'success', - 1 => 'warning', - ]) - ->columnSpan(2), - - TagsInput::make('threads') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => !$get('cpu_pinning')) - ->label(trans('admin/server.threads'))->inlineLabel() - ->required(fn (Get $get) => $get('cpu_pinning')) - ->columnSpan(2) - ->separator() - ->splitKeys([',']) - ->placeholder(trans('admin/server.pin_help')), - ]), - ToggleButtons::make('swap_support') - ->live() - ->label(trans('admin/server.swap'))->inlineLabel()->inline() - ->columnSpan(2) - ->afterStateUpdated(function ($state, Set $set) { - $value = match ($state) { - 'unlimited' => -1, - 'disabled' => 0, - 'limited' => 128, - default => throw new LogicException('Invalid state') - }; - - $set('swap', $value); - }) - ->formatStateUsing(function (Get $get) { - return match (true) { - $get('swap') > 0 => 'limited', - $get('swap') == 0 => 'disabled', - $get('swap') < 0 => 'unlimited', - default => throw new LogicException('Invalid state') - }; - }) - ->options([ - 'unlimited' => trans('admin/server.unlimited'), - 'limited' => trans('admin/server.limited'), - 'disabled' => trans('admin/server.disabled'), - ]) - ->colors([ - 'unlimited' => 'primary', - 'limited' => 'warning', - 'disabled' => 'danger', - ]), - - TextInput::make('swap') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => match ($get('swap_support')) { - 'disabled', 'unlimited', true => true, - default => false, - }) - ->label(trans('admin/server.swap'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->minValue(-1) - ->columnSpan(2) - ->required() - ->integer(), - ]), - - Hidden::make('io') - ->helperText('The IO performance relative to other running containers') - ->label('Block IO Proportion'), - - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('oom_killer') - ->dehydrated() - ->label(trans('admin/server.oom')) - ->formatStateUsing(fn ($state) => $state) - ->inlineLabel() - ->inline() - ->columnSpan(2) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.disabled'), - 1 => trans('admin/server.enabled'), - ]) - ->colors([ - 0 => 'success', - 1 => 'danger', - ]), - ]), - ]), - - Fieldset::make(trans('admin/server.feature_limits')) - ->inlineLabel() - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - TextInput::make('allocation_limit') - ->label(trans('admin/server.allocations')) - ->suffixIcon(TablerIcon::Network) - ->required() - ->minValue(0) - ->numeric(), - TextInput::make('database_limit') - ->label(trans('admin/server.databases')) - ->suffixIcon(TablerIcon::Database) - ->required() - ->minValue(0) - ->numeric(), - TextInput::make('backup_limit') - ->label(trans('admin/server.backups')) - ->suffixIcon(TablerIcon::CopyCheck) - ->required() - ->minValue(0) - ->numeric(), - ]), - Fieldset::make(trans('admin/server.docker_settings')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 4, - ]) - ->schema([ - Select::make('select_image') - ->label(trans('admin/server.image_name')) - ->live() - ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) - ->options(function ($state, Get $get, Set $set) { - $egg = Egg::query()->find($get('egg_id')); - $images = $egg->docker_images ?? []; - - $currentImage = $get('image'); - if (!$currentImage && $images) { - $defaultImage = collect($images)->first(); - $set('image', $defaultImage); - $set('select_image', $defaultImage); - } - - return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image']; - }) - ->selectablePlaceholder(false) - ->columnSpan([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 2, - ]), - - TextInput::make('image') - ->label(trans('admin/server.image')) - ->required() - ->afterStateUpdated(function ($state, Get $get, Set $set) { - $egg = Egg::query()->find($get('egg_id')); - $images = $egg->docker_images ?? []; - - if (in_array($state, $images)) { - $set('select_image', $state); - } else { - $set('select_image', 'ghcr.io/custom-image'); - } - }) - ->placeholder(trans('admin/server.image_placeholder')) - ->columnSpan([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 2, - ]), - - KeyValue::make('docker_labels') - ->live() - ->label(trans('admin/server.container_labels')) - ->keyLabel(trans('admin/server.title')) - ->valueLabel(trans('admin/server.description')) - ->columnSpanFull(), - ]), - ]), - Tab::make('egg') - ->label(trans('admin/server.egg')) - ->icon(TablerIcon::Egg) - ->columns([ - 'default' => 1, - 'sm' => 3, - 'md' => 3, - 'lg' => 5, - ]) - ->schema([ - Select::make('egg_id') - ->disabled() - ->prefixIcon(TablerIcon::Egg) - ->columnSpan([ - 'default' => 6, - 'sm' => 3, - 'md' => 3, - 'lg' => 4, - ]) - ->relationship('egg', 'name') - ->label(trans('admin/server.name')) - ->searchable() - ->preload() - ->required() - ->hintAction( - Action::make('hint_change_egg') - ->label(trans('admin/server.change_egg')) - ->action(function (array $data, Server $server, EggChangerService $service) { - $service->handle($server, $data['egg_id'], $data['keep_old_variables']); - - // Use redirect instead of fillForm to prevent server variables from duplicating - $this->redirect($this->getUrl(['record' => $server, 'tab' => 'egg::data::tab']), true); - }) - ->schema(fn (Server $server) => [ - Select::make('egg_id') - ->label(trans('admin/server.new_egg')) - ->prefixIcon(TablerIcon::Egg) - ->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name])) - ->searchable() - ->preload() - ->required(), - Toggle::make('keep_old_variables') - ->label(trans('admin/server.keep_old_variables')) - ->default(true), - ]) - ), - - ToggleButtons::make('skip_scripts') - ->label(trans('admin/server.install_script')) - ->inline() - ->columnSpan([ - 'default' => 6, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.yes'), - 1 => trans('admin/server.skip'), - ]) - ->colors([ - 0 => 'primary', - 1 => 'danger', - ]) - ->icons([ - 0 => TablerIcon::Code, - 1 => TablerIcon::CodeOff, - ]) - ->required(), - - Hidden::make('previewing') - ->default(false), - - Select::make('select_startup') - ->label(trans('admin/server.startup_cmd')) - ->required() - ->live() - ->options(function (Get $get) { - $egg = Egg::find($get('egg_id')); - - return array_flip($egg->startup_commands ?? []) + ['custom' => 'Custom Startup']; - }) - ->formatStateUsing(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands) ? $server->startup : 'custom') - ->afterStateUpdated(function (Set $set, string $state) { - if ($state !== 'custom') { - $set('startup', $state); - } - $set('previewing', false); - }) - ->selectablePlaceholder(false) - ->columnSpanFull() - ->hintAction(PreviewStartupAction::make('hint_preview')), - - Textarea::make('startup') - ->hiddenLabel() - ->required() - ->live() - ->autosize() - ->afterStateUpdated(function ($state, Get $get, Set $set) { - $egg = Egg::find($get('egg_id')); - $startups = $egg->startup_commands ?? []; - - if (in_array($state, $startups)) { - $set('select_startup', $state); - } else { - $set('select_startup', 'custom'); - } - }) - ->placeholder(trans('admin/server.startup_placeholder')) - ->columnSpanFull(), - - Repeater::make('server_variables') - ->hiddenLabel() - ->relationship('serverVariables', function (Builder $query) { - /** @var Server $server */ - $server = $this->getRecord(); - - $server->ensureVariablesExist(); - - return $query->orderByPowerJoins('variable.sort'); - }) - ->grid() - ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { - $data['variable_value'] ??= ''; - - return $data; - }) - ->reorderable(false)->addable(false)->deletable(false) - ->schema([ - StartupVariable::make('variable_value') - ->fromRecord() - ->disabled(false), - ]) - ->columnSpan(6), - ]), - Tab::make('mounts') - ->label(trans('admin/server.mounts')) - ->icon(TablerIcon::LayersLinked) - ->schema(fn (Get $get) => [ - ServerResource::getMountCheckboxList($get), - ]), - Tab::make('actions') - ->label(trans('admin/server.actions')) - ->icon(TablerIcon::Settings) - ->schema([ - Fieldset::make(trans('admin/server.actions')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 2, - 'lg' => 6, - ]) - ->schema([ - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_toggle_install') - ->label(trans('admin/server.toggle_install')) - ->disabled(fn (Server $server) => $server->isSuspended()) - ->modal(fn (Server $server) => $server->isFailedInstall()) - ->modalHeading(trans('admin/server.toggle_install_failed_header')) - ->modalDescription(trans('admin/server.toggle_install_failed_desc')) - ->modalSubmitActionLabel(trans('admin/server.reinstall')) - ->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) { - if ($server->isFailedInstall()) { - try { - $reinstallService->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.reinstall_started')) - ->success() - ->send(); - - } catch (Exception) { - Notification::make() - ->title(trans('admin/server.notifications.reinstall_failed')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->danger() - ->send(); - } - } else { - try { - $toggleService->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.install_toggled')) - ->success() - ->send(); - - } catch (Exception $exception) { - Notification::make() - ->title(trans('admin/server.notifications.install_toggle_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - } - }), - ])->fullWidth(), - ToggleButtons::make('install_help') - ->hiddenLabel() - ->hint(trans('admin/server.toggle_install_help')), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_toggle_suspend') - ->label(trans('admin/server.suspend')) - ->color('warning') - ->hidden(fn (Server $server) => $server->isSuspended()) - ->action(function (SuspensionService $suspensionService, Server $server) { - try { - $suspensionService->handle($server, SuspendAction::Suspend); - - Notification::make() - ->success() - ->title(trans('admin/server.notifications.server_suspended')) - ->send(); - - } catch (Exception) { - Notification::make() - ->warning() - ->title(trans('admin/server.notifications.server_suspension')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->send(); - } - }), - Action::make('exclude_toggle_unsuspend') - ->label(trans('admin/server.unsuspend')) - ->color('success') - ->hidden(fn (Server $server) => !$server->isSuspended()) - ->action(function (SuspensionService $suspensionService, Server $server) { - try { - $suspensionService->handle($server, SuspendAction::Unsuspend); - - Notification::make() - ->success() - ->title(trans('admin/server.notifications.server_unsuspended')) - ->send(); - - } catch (Exception) { - Notification::make() - ->warning() - ->title(trans('admin/server.notifications.server_suspension')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_suspend') - ->hiddenLabel() - ->hidden(fn (Server $server) => $server->isSuspended()) - ->hint(trans('admin/server.notifications.server_suspend_help')), - ToggleButtons::make('server_unsuspend') - ->hiddenLabel() - ->hidden(fn (Server $server) => !$server->isSuspended()) - ->hint(trans('admin/server.notifications.server_unsuspend_help')), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_transfer') - ->label(trans('admin/server.transfer')) - ->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState()) - ->modalHeading(trans('admin/server.transfer')) - ->schema($this->transferServer()) - ->action(function (TransferServerService $transfer, Server $server, $data) { - try { - $selectedBackupUuids = Arr::get($data, 'backups', []); - $transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids); - - $server->backups - ->whereNotIn('uuid', $selectedBackupUuids) - ->where('disk', Backup::ADAPTER_DAEMON) - ->each(function ($backup) { - $backup->delete(); - }); - - Notification::make() - ->title(trans('admin/server.notifications.transfer_started')) - ->success() - ->send(); - } catch (Exception $exception) { - Notification::make() - ->title(trans('admin/server.notifications.transfer_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_transfer') - ->hiddenLabel() - ->hint(new HtmlString(trans('admin/server.transfer_help'))), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_reinstall') - ->label(trans('admin/server.reinstall')) - ->color('danger') - ->requiresConfirmation() - ->modalHeading(trans('admin/server.reinstall_modal_heading')) - ->modalDescription(trans('admin/server.reinstall_modal_description')) - ->disabled(fn (Server $server) => $server->isSuspended()) - ->action(function (ReinstallServerService $service, Server $server) { - try { - $service->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.reinstall_started')) - ->success() - ->send(); - } catch (Exception) { - Notification::make() - ->title(trans('admin/server.notifications.reinstall_failed')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->danger() - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_reinstall') - ->hiddenLabel() - ->hint(trans('admin/server.reinstall_help')), - ]), - ]), - ]), - ]; - } - - /** @return Component[] - * @throws Exception - */ - protected function transferServer(): array - { - return [ - Select::make('node_id') - ->label(trans('admin/server.node')) - ->prefixIcon(TablerIcon::Server2) - ->selectablePlaceholder(false) - ->default(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->first()?->id) - ->required() - ->live() - ->options(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->pluck('name', 'id')->all()), - Select::make('allocation_id') - ->label(trans('admin/server.primary_allocation')) - ->disabled(fn (Get $get, Server $server) => !$get('node_id') || !$server->allocation_id) - ->required(fn (Server $server) => $server->allocation_id) - ->prefixIcon(TablerIcon::Network) - ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) - ->searchable(['ip', 'port', 'ip_alias']) - ->placeholder(trans('admin/server.select_allocation')), - Select::make('allocation_additional') - ->label(trans('admin/server.additional_allocations')) - ->disabled(fn (Get $get, Server $server) => !$get('node_id') || $server->allocations->count() <= 1) - ->multiple() - ->minItems(fn (Select $select) => $select->getMaxItems()) - ->maxItems(fn (Select $select, Server $server) => $select->isDisabled() ? null : $server->allocations->count() - 1) - ->prefixIcon(TablerIcon::Network) - ->required(fn (Server $server) => $server->allocations->count() > 1) - ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) - ->searchable(['ip', 'port', 'ip_alias']) - ->placeholder(trans('admin/server.select_additional')), - Grid::make() - ->columnSpanFull() - ->schema([ - CheckboxList::make('backups') - ->label(trans('admin/server.backups')) - ->bulkToggleable() - ->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name])) - ->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)), - Text::make('backup_helper') - ->columnSpanFull() - ->content(trans('admin/server.warning_backups')), - ]) - ->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0), - ]; + return $this->detailTabs(); } /** @return array */ diff --git a/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php b/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php new file mode 100644 index 0000000000..65801ce27c --- /dev/null +++ b/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php @@ -0,0 +1,67 @@ +components([ + Tabs::make('Tabs') + ->persistTabInQueryString() + ->columns([ + 'default' => 2, + 'sm' => 2, + 'md' => 4, + 'lg' => 6, + ]) + ->columnSpanFull() + ->tabs($this->getTabs()), + ]); + } + + /** + * @return Tab[] + * + * @throws RandomException + */ + protected function getDefaultTabs(): array + { + return $this->detailTabs(); + } + + /** @return array */ + protected function getDefaultHeaderActions(): array + { + return [ + EditAction::make(), + ]; + } + + protected function materializesServerVariables(): bool + { + return false; + } +} diff --git a/app/Filament/Admin/Resources/Servers/ServerResource.php b/app/Filament/Admin/Resources/Servers/ServerResource.php index cc0adea0d9..58b885c806 100644 --- a/app/Filament/Admin/Resources/Servers/ServerResource.php +++ b/app/Filament/Admin/Resources/Servers/ServerResource.php @@ -7,6 +7,7 @@ use App\Filament\Admin\Resources\Servers\Pages\CreateServer; use App\Filament\Admin\Resources\Servers\Pages\EditServer; use App\Filament\Admin\Resources\Servers\Pages\ListServers; +use App\Filament\Admin\Resources\Servers\Pages\ViewServer; use App\Filament\Admin\Resources\Servers\RelationManagers\AllocationsRelationManager; use App\Filament\Admin\Resources\Servers\RelationManagers\DatabasesRelationManager; use App\Models\Mount; @@ -99,6 +100,7 @@ public static function getDefaultPages(): array return [ 'index' => ListServers::route('/'), 'create' => CreateServer::route('/create'), + 'view' => ViewServer::route('/{record}'), 'edit' => EditServer::route('/{record}/edit'), ]; } diff --git a/app/Traits/Filament/ServerDetailTabs.php b/app/Traits/Filament/ServerDetailTabs.php new file mode 100644 index 0000000000..e29e3b4214 --- /dev/null +++ b/app/Traits/Filament/ServerDetailTabs.php @@ -0,0 +1,953 @@ +getRecord(), $this->redirect(), $this->transferServer()), + * so it lives in a trait rather than a static like Egg. + */ +trait ServerDetailTabs +{ + /** + * Whether the edit-time scaffolding (materializing missing server + * variable rows) runs. ViewServer overrides this to false so that + * merely viewing the page never writes. + */ + protected function materializesServerVariables(): bool + { + return true; + } + + /** + * @return Tab[] + * + * @throws RandomException + */ + protected function detailTabs(): array + { + return [ + Tab::make('information') + ->label(trans('admin/server.tabs.information')) + ->icon(TablerIcon::InfoCircle) + ->schema([ + Grid::make() + ->columnStart(1) + ->schema([ + Image::make('', 'icon') + ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) + ->url(fn ($record) => $record->icon ?: $record->egg->icon) + ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) + ->imageSize(150) + ->columnSpanFull() + ->alignJustify(), + UploadIcon::make() + ->hidden(fn (string $operation) => $operation === 'view'), + DeleteIcon::make() + ->iconStoragePath(Server::getIconStoragePath()) + ->hidden(fn (string $operation) => $operation === 'view'), + ]), + Grid::make() + ->columns(3) + ->columnStart(2) + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 3, + 'lg' => 5, + ]) + ->schema([ + TextInput::make('name') + ->prefixIcon(TablerIcon::Server) + ->label(trans('admin/server.name')) + ->suffixAction(Action::make('hint_random') + ->tooltip('Random') + ->icon('tabler-dice-' . random_int(1, 6)) + ->action(function (Set $set, Get $get) { + $egg = Egg::find($get('egg_id')); + $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; + + $word = (new RandomWordService())->word(); + + $set('name', $prefix . $word); + }) + ->hidden(fn (string $operation) => $operation === 'view')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->required() + ->maxLength(255), + Select::make('owner_id') + ->prefixIcon(TablerIcon::User) + ->label(trans('admin/server.owner')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->relationship('user', 'username') + ->searchable(['username', 'email']) + ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)") + ->preload() + ->required(), + ToggleButtons::make('condition') + ->label(trans('admin/server.server_status')) + ->formatStateUsing(fn (Server $server) => $server->condition) + ->options(fn ($state) => [$state->value => $state->getLabel()]) + ->colors(fn ($state) => [$state->value => $state->getColor()]) + ->icons(fn ($state) => [$state->value => $state->getIcon()]) + ->stateCast(new ServerConditionStateCast()) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->hintAction( + Action::make('view_install_log') + ->label(trans('admin/server.view_install_log')) + //->visible(fn (Server $server) => $server->isFailedInstall()) + ->modalHeading('') + ->modalSubmitAction(false) + ->modalFooterActionsAlignment(Alignment::Right) + ->modalCancelActionLabel(trans('filament::components/modal.actions.close.label')) + ->schema([ + MonacoEditor::make('logs') + ->hiddenLabel() + ->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) { + try { + $logs = $serverRepository->setServer($server)->getInstallLogs(); + + return convert_to_utf8($logs); + } catch (ConnectionException) { + Notification::make() + ->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->body(trans('admin/server.notifications.log_failed')) + ->color('warning') + ->warning() + ->send(); + } catch (Exception) { + return ''; + } + + return ''; + }), + ]) + ->hidden(fn (string $operation) => $operation === 'view') + ), + ]), + Textarea::make('description') + ->label(trans('admin/server.description')) + ->columnSpanFull(), + TextInput::make('uuid') + ->label(trans('admin/server.uuid')) + ->copyable() + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly() + ->dehydrated(false), + TextInput::make('uuid_short') + ->label(trans('admin/server.short_uuid')) + ->copyable() + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly() + ->dehydrated(false), + TextInput::make('external_id') + ->label(trans('admin/server.external_id')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->unique() + ->maxLength(255), + Select::make('node_id') + ->label(trans('admin/server.node')) + ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id'))) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->disabled(), + ]), + Tab::make('environment_configuration') + ->label(trans('admin/server.tabs.environment_configuration')) + ->icon(TablerIcon::BrandDocker) + ->schema([ + Fieldset::make(trans('admin/server.resource_limits')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_cpu') + ->dehydrated() + ->label(trans('admin/server.cpu'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) + ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/server.cpu_limit'))->inlineLabel() + ->suffix('%') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.cpu_helper')) + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_mem') + ->dehydrated() + ->label(trans('admin/server.memory'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) + ->formatStateUsing(fn (Get $get) => $get('memory') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->label(trans('admin/server.memory_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.memory_helper')) + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_disk') + ->dehydrated() + ->label(trans('admin/server.disk'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) + ->formatStateUsing(fn (Get $get) => $get('disk') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/server.disk_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + ]), + + Fieldset::make(trans('admin/server.advanced_limits')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline() + ->default(0) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.disabled'), + 1 => trans('admin/server.enabled'), + ]) + ->colors([ + 0 => 'success', + 1 => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label(trans('admin/server.threads'))->inlineLabel() + ->required(fn (Get $get) => $get('cpu_pinning')) + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder(trans('admin/server.pin_help')), + ]), + ToggleButtons::make('swap_support') + ->live() + ->label(trans('admin/server.swap'))->inlineLabel()->inline() + ->columnSpan(2) + ->afterStateUpdated(function ($state, Set $set) { + $value = match ($state) { + 'unlimited' => -1, + 'disabled' => 0, + 'limited' => 128, + default => throw new LogicException('Invalid state') + }; + + $set('swap', $value); + }) + ->formatStateUsing(function (Get $get) { + return match (true) { + $get('swap') > 0 => 'limited', + $get('swap') == 0 => 'disabled', + $get('swap') < 0 => 'unlimited', + default => throw new LogicException('Invalid state') + }; + }) + ->options([ + 'unlimited' => trans('admin/server.unlimited'), + 'limited' => trans('admin/server.limited'), + 'disabled' => trans('admin/server.disabled'), + ]) + ->colors([ + 'unlimited' => 'primary', + 'limited' => 'warning', + 'disabled' => 'danger', + ]), + + TextInput::make('swap') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => match ($get('swap_support')) { + 'disabled', 'unlimited', true => true, + default => false, + }) + ->label(trans('admin/server.swap'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->minValue(-1) + ->columnSpan(2) + ->required() + ->integer(), + ]), + + Hidden::make('io') + ->helperText('The IO performance relative to other running containers') + ->label('Block IO Proportion'), + + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('oom_killer') + ->dehydrated() + ->label(trans('admin/server.oom')) + ->formatStateUsing(fn ($state) => $state) + ->inlineLabel() + ->inline() + ->columnSpan(2) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.disabled'), + 1 => trans('admin/server.enabled'), + ]) + ->colors([ + 0 => 'success', + 1 => 'danger', + ]), + ]), + ]), + + Fieldset::make(trans('admin/server.feature_limits')) + ->inlineLabel() + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + TextInput::make('allocation_limit') + ->label(trans('admin/server.allocations')) + ->suffixIcon(TablerIcon::Network) + ->required() + ->minValue(0) + ->numeric(), + TextInput::make('database_limit') + ->label(trans('admin/server.databases')) + ->suffixIcon(TablerIcon::Database) + ->required() + ->minValue(0) + ->numeric(), + TextInput::make('backup_limit') + ->label(trans('admin/server.backups')) + ->suffixIcon(TablerIcon::CopyCheck) + ->required() + ->minValue(0) + ->numeric(), + ]), + Fieldset::make(trans('admin/server.docker_settings')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 4, + ]) + ->schema([ + Select::make('select_image') + ->label(trans('admin/server.image_name')) + ->live() + ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) + ->options(function ($state, Get $get, Set $set) { + $egg = Egg::query()->find($get('egg_id')); + $images = $egg->docker_images ?? []; + + $currentImage = $get('image'); + if (!$currentImage && $images) { + $defaultImage = collect($images)->first(); + $set('image', $defaultImage); + $set('select_image', $defaultImage); + } + + return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image']; + }) + ->selectablePlaceholder(false) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 2, + ]), + + TextInput::make('image') + ->label(trans('admin/server.image')) + ->required() + ->afterStateUpdated(function ($state, Get $get, Set $set) { + $egg = Egg::query()->find($get('egg_id')); + $images = $egg->docker_images ?? []; + + if (in_array($state, $images)) { + $set('select_image', $state); + } else { + $set('select_image', 'ghcr.io/custom-image'); + } + }) + ->placeholder(trans('admin/server.image_placeholder')) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 2, + ]), + + KeyValue::make('docker_labels') + ->live() + ->label(trans('admin/server.container_labels')) + ->keyLabel(trans('admin/server.title')) + ->valueLabel(trans('admin/server.description')) + ->columnSpanFull(), + ]), + ]), + Tab::make('egg') + ->label(trans('admin/server.egg')) + ->icon(TablerIcon::Egg) + ->columns([ + 'default' => 1, + 'sm' => 3, + 'md' => 3, + 'lg' => 5, + ]) + ->schema([ + Select::make('egg_id') + ->disabled() + ->prefixIcon(TablerIcon::Egg) + ->columnSpan([ + 'default' => 6, + 'sm' => 3, + 'md' => 3, + 'lg' => 4, + ]) + ->relationship('egg', 'name') + ->label(trans('admin/server.name')) + ->searchable() + ->preload() + ->required() + ->hintAction( + Action::make('hint_change_egg') + ->label(trans('admin/server.change_egg')) + ->action(function (array $data, Server $server, EggChangerService $service) { + $service->handle($server, $data['egg_id'], $data['keep_old_variables']); + + // Use redirect instead of fillForm to prevent server variables from duplicating + $this->redirect($this->getUrl(['record' => $server, 'tab' => 'egg::data::tab']), true); + }) + ->schema(fn (Server $server) => [ + Select::make('egg_id') + ->label(trans('admin/server.new_egg')) + ->prefixIcon(TablerIcon::Egg) + ->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name])) + ->searchable() + ->preload() + ->required(), + Toggle::make('keep_old_variables') + ->label(trans('admin/server.keep_old_variables')) + ->default(true), + ]) + ->hidden(fn (string $operation) => $operation === 'view') + ), + + ToggleButtons::make('skip_scripts') + ->label(trans('admin/server.install_script')) + ->inline() + ->columnSpan([ + 'default' => 6, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.yes'), + 1 => trans('admin/server.skip'), + ]) + ->colors([ + 0 => 'primary', + 1 => 'danger', + ]) + ->icons([ + 0 => TablerIcon::Code, + 1 => TablerIcon::CodeOff, + ]) + ->required(), + + Hidden::make('previewing') + ->default(false), + + Select::make('select_startup') + ->label(trans('admin/server.startup_cmd')) + ->required() + ->live() + ->options(function (Get $get) { + $egg = Egg::find($get('egg_id')); + + return array_flip($egg->startup_commands ?? []) + ['custom' => 'Custom Startup']; + }) + ->formatStateUsing(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands) ? $server->startup : 'custom') + ->afterStateUpdated(function (Set $set, string $state) { + if ($state !== 'custom') { + $set('startup', $state); + } + $set('previewing', false); + }) + ->selectablePlaceholder(false) + ->columnSpanFull() + ->hintAction(PreviewStartupAction::make('hint_preview') + ->hidden(fn (string $operation) => $operation === 'view')), + + Textarea::make('startup') + ->hiddenLabel() + ->required() + ->live() + ->autosize() + ->afterStateUpdated(function ($state, Get $get, Set $set) { + $egg = Egg::find($get('egg_id')); + $startups = $egg->startup_commands ?? []; + + if (in_array($state, $startups)) { + $set('select_startup', $state); + } else { + $set('select_startup', 'custom'); + } + }) + ->placeholder(trans('admin/server.startup_placeholder')) + ->columnSpanFull(), + + Repeater::make('server_variables') + ->hiddenLabel() + ->relationship('serverVariables', function (Builder $query) { + /** @var Server $server */ + $server = $this->getRecord(); + + if ($this->materializesServerVariables()) { + $server->ensureVariablesExist(); + } + + return $query->orderByPowerJoins('variable.sort'); + }) + ->grid() + ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { + $data['variable_value'] ??= ''; + + return $data; + }) + ->reorderable(false)->addable(false)->deletable(false) + ->schema([ + StartupVariable::make('variable_value') + ->fromRecord() + ->disabled(false), + ]) + ->columnSpan(6), + ]), + Tab::make('mounts') + ->label(trans('admin/server.mounts')) + ->icon(TablerIcon::LayersLinked) + ->schema(fn (Get $get) => [ + ServerResource::getMountCheckboxList($get), + ]), + Tab::make('actions') + ->label(trans('admin/server.actions')) + ->icon(TablerIcon::Settings) + ->hidden(fn (string $operation) => $operation === 'view') + ->schema([ + Fieldset::make(trans('admin/server.actions')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 2, + 'lg' => 6, + ]) + ->schema([ + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_toggle_install') + ->label(trans('admin/server.toggle_install')) + ->disabled(fn (Server $server) => $server->isSuspended()) + ->modal(fn (Server $server) => $server->isFailedInstall()) + ->modalHeading(trans('admin/server.toggle_install_failed_header')) + ->modalDescription(trans('admin/server.toggle_install_failed_desc')) + ->modalSubmitActionLabel(trans('admin/server.reinstall')) + ->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) { + if ($server->isFailedInstall()) { + try { + $reinstallService->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.reinstall_started')) + ->success() + ->send(); + + } catch (Exception) { + Notification::make() + ->title(trans('admin/server.notifications.reinstall_failed')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->danger() + ->send(); + } + } else { + try { + $toggleService->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.install_toggled')) + ->success() + ->send(); + + } catch (Exception $exception) { + Notification::make() + ->title(trans('admin/server.notifications.install_toggle_failed')) + ->body($exception->getMessage()) + ->danger() + ->send(); + } + } + }), + ])->fullWidth(), + ToggleButtons::make('install_help') + ->hiddenLabel() + ->hint(trans('admin/server.toggle_install_help')), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_toggle_suspend') + ->label(trans('admin/server.suspend')) + ->color('warning') + ->hidden(fn (Server $server) => $server->isSuspended()) + ->action(function (SuspensionService $suspensionService, Server $server) { + try { + $suspensionService->handle($server, SuspendAction::Suspend); + + Notification::make() + ->success() + ->title(trans('admin/server.notifications.server_suspended')) + ->send(); + + } catch (Exception) { + Notification::make() + ->warning() + ->title(trans('admin/server.notifications.server_suspension')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->send(); + } + }), + Action::make('exclude_toggle_unsuspend') + ->label(trans('admin/server.unsuspend')) + ->color('success') + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->action(function (SuspensionService $suspensionService, Server $server) { + try { + $suspensionService->handle($server, SuspendAction::Unsuspend); + + Notification::make() + ->success() + ->title(trans('admin/server.notifications.server_unsuspended')) + ->send(); + + } catch (Exception) { + Notification::make() + ->warning() + ->title(trans('admin/server.notifications.server_suspension')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_suspend') + ->hiddenLabel() + ->hidden(fn (Server $server) => $server->isSuspended()) + ->hint(trans('admin/server.notifications.server_suspend_help')), + ToggleButtons::make('server_unsuspend') + ->hiddenLabel() + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->hint(trans('admin/server.notifications.server_unsuspend_help')), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_transfer') + ->label(trans('admin/server.transfer')) + ->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState()) + ->modalHeading(trans('admin/server.transfer')) + ->schema($this->transferServer()) + ->action(function (TransferServerService $transfer, Server $server, $data) { + try { + $selectedBackupUuids = Arr::get($data, 'backups', []); + $transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids); + + $server->backups + ->whereNotIn('uuid', $selectedBackupUuids) + ->where('disk', Backup::ADAPTER_DAEMON) + ->each(function ($backup) { + $backup->delete(); + }); + + Notification::make() + ->title(trans('admin/server.notifications.transfer_started')) + ->success() + ->send(); + } catch (Exception $exception) { + Notification::make() + ->title(trans('admin/server.notifications.transfer_failed')) + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_transfer') + ->hiddenLabel() + ->hint(new HtmlString(trans('admin/server.transfer_help'))), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_reinstall') + ->label(trans('admin/server.reinstall')) + ->color('danger') + ->requiresConfirmation() + ->modalHeading(trans('admin/server.reinstall_modal_heading')) + ->modalDescription(trans('admin/server.reinstall_modal_description')) + ->disabled(fn (Server $server) => $server->isSuspended()) + ->action(function (ReinstallServerService $service, Server $server) { + try { + $service->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.reinstall_started')) + ->success() + ->send(); + } catch (Exception) { + Notification::make() + ->title(trans('admin/server.notifications.reinstall_failed')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->danger() + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_reinstall') + ->hiddenLabel() + ->hint(trans('admin/server.reinstall_help')), + ]), + ]), + ]), + ]; + } + + /** @return Component[] + * @throws Exception + */ + protected function transferServer(): array + { + return [ + Select::make('node_id') + ->label(trans('admin/server.node')) + ->prefixIcon(TablerIcon::Server2) + ->selectablePlaceholder(false) + ->default(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->first()?->id) + ->required() + ->live() + ->options(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->pluck('name', 'id')->all()), + Select::make('allocation_id') + ->label(trans('admin/server.primary_allocation')) + ->disabled(fn (Get $get, Server $server) => !$get('node_id') || !$server->allocation_id) + ->required(fn (Server $server) => $server->allocation_id) + ->prefixIcon(TablerIcon::Network) + ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) + ->searchable(['ip', 'port', 'ip_alias']) + ->placeholder(trans('admin/server.select_allocation')), + Select::make('allocation_additional') + ->label(trans('admin/server.additional_allocations')) + ->disabled(fn (Get $get, Server $server) => !$get('node_id') || $server->allocations->count() <= 1) + ->multiple() + ->minItems(fn (Select $select) => $select->getMaxItems()) + ->maxItems(fn (Select $select, Server $server) => $select->isDisabled() ? null : $server->allocations->count() - 1) + ->prefixIcon(TablerIcon::Network) + ->required(fn (Server $server) => $server->allocations->count() > 1) + ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) + ->searchable(['ip', 'port', 'ip_alias']) + ->placeholder(trans('admin/server.select_additional')), + Grid::make() + ->columnSpanFull() + ->schema([ + CheckboxList::make('backups') + ->label(trans('admin/server.backups')) + ->bulkToggleable() + ->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name])) + ->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)), + Text::make('backup_helper') + ->columnSpanFull() + ->content(trans('admin/server.warning_backups')), + ]) + ->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0), + ]; + } +} From 9917cef29146e9cce17f1a7c5255a5aba517953d Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 14:50:39 +0100 Subject: [PATCH 12/20] add view row action to server list table --- .../Admin/Resources/Servers/Pages/ListServers.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Filament/Admin/Resources/Servers/Pages/ListServers.php b/app/Filament/Admin/Resources/Servers/Pages/ListServers.php index 189ac4d717..15859c0e15 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/ListServers.php +++ b/app/Filament/Admin/Resources/Servers/Pages/ListServers.php @@ -11,6 +11,7 @@ use Filament\Actions\Action; use Filament\Actions\CreateAction; use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; use Filament\Resources\Pages\ListRecords; use Filament\Tables\Columns\SelectColumn; use Filament\Tables\Columns\TextColumn; @@ -90,8 +91,11 @@ public function table(Table $table): Table ->sortable(), ]) ->recordActions([ - Action::make('view') - ->tooltip(trans('admin/server.view')) + ViewAction::make() + ->tooltip(trans('filament-actions::view.single.label')) + ->hidden(fn (Server $record) => ServerResource::getEditAuthorizationResponse($record)->allowed()), + Action::make('console') + ->tooltip(trans('admin/server.console')) ->icon(TablerIcon::Terminal) ->url(fn (Server $server) => Console::getUrl(panel: 'server', tenant: $server)) ->authorize(fn (Server $server) => user()?->canAccessTenant($server)), From 722edd30bbf053cd04ff4840a71b01773930ae60 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 15:42:54 +0100 Subject: [PATCH 13/20] make server relation managers read-only on view --- .../AllocationsRelationManager.php | 24 ++++++++++++++----- .../DatabasesRelationManager.php | 5 +++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php b/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php index 01d8a65254..36bf6ba583 100644 --- a/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php +++ b/app/Filament/Admin/Resources/Servers/RelationManagers/AllocationsRelationManager.php @@ -46,10 +46,12 @@ public function table(Table $table): Table TextColumn::make('port') ->label(trans('admin/server.port')), TextInputColumn::make('ip_alias') - ->label(trans('admin/server.alias')), + ->label(trans('admin/server.alias')) + ->disabled(fn () => $this->isReadOnly()), TextInputColumn::make('notes') ->label(trans('admin/server.notes')) - ->placeholder(trans('admin/server.no_notes')), + ->placeholder(trans('admin/server.no_notes')) + ->disabled(fn () => $this->isReadOnly()), IconColumn::make('primary') ->icon(fn ($state) => match ($state) { true => TablerIcon::StarFilled, @@ -60,7 +62,13 @@ public function table(Table $table): Table default => 'gray', }) ->tooltip(fn (Allocation $allocation) => trans('admin/server.' . ($allocation->id === $this->getOwnerRecord()->allocation_id ? 'already' : 'make') . '_primary')) - ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) + ->action(function (Allocation $allocation) { + if ($this->isReadOnly()) { + return; + } + + $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords(); + }) ->default(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id) ->label(trans('admin/server.primary')), IconColumn::make('is_locked') @@ -73,16 +81,17 @@ public function table(Table $table): Table Action::make('make-primary') ->label(trans('admin/server.make_primary')) ->action(fn (Allocation $allocation) => $this->getOwnerRecord()->update(['allocation_id' => $allocation->id]) && $this->deselectAllTableRecords()) - ->hidden(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id), + ->hidden(fn (Allocation $allocation) => $this->isReadOnly() || $allocation->id === $this->getOwnerRecord()->allocation_id), Action::make('lock') ->label(trans('admin/server.lock')) ->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => true]) && $this->deselectAllTableRecords()) - ->hidden(fn (Allocation $allocation) => $allocation->is_locked), + ->hidden(fn (Allocation $allocation) => $this->isReadOnly() || $allocation->is_locked), Action::make('unlock') ->label(trans('admin/server.unlock')) ->action(fn (Allocation $allocation) => $allocation->update(['is_locked' => false]) && $this->deselectAllTableRecords()) - ->visible(fn (Allocation $allocation) => $allocation->is_locked), + ->visible(fn (Allocation $allocation) => !$this->isReadOnly() && $allocation->is_locked), DissociateAction::make() + ->hidden(fn () => $this->isReadOnly()) ->after(function (Allocation $allocation) { $allocation->update([ 'notes' => null, @@ -96,6 +105,7 @@ public function table(Table $table): Table ]) ->toolbarActions([ DissociateBulkAction::make() + ->hidden(fn () => $this->isReadOnly()) ->after(function () { Allocation::whereNull('server_id')->update([ 'notes' => null, @@ -107,6 +117,7 @@ public function table(Table $table): Table } }), CreateAction::make() + ->hidden(fn () => $this->isReadOnly()) ->hiddenLabel() ->tooltip(trans('admin/server.create_allocation')) ->icon(TablerIcon::Network) @@ -162,6 +173,7 @@ public function table(Table $table): Table ]) ->action(fn (array $data, AssignmentService $service) => $service->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())), AssociateAction::make() + ->hidden(fn () => $this->isReadOnly()) ->icon(TablerIcon::FilePlus) ->iconButton()->iconSize(IconSize::ExtraLarge) ->multiple() diff --git a/app/Filament/Admin/Resources/Servers/RelationManagers/DatabasesRelationManager.php b/app/Filament/Admin/Resources/Servers/RelationManagers/DatabasesRelationManager.php index 069e60c326..fc0ba342a8 100644 --- a/app/Filament/Admin/Resources/Servers/RelationManagers/DatabasesRelationManager.php +++ b/app/Filament/Admin/Resources/Servers/RelationManagers/DatabasesRelationManager.php @@ -42,7 +42,8 @@ public function form(Schema $schema): Schema ->label(trans('admin/databasehost.table.password')) ->password() ->revealable() - ->hintAction(RotateDatabasePasswordAction::make()) + ->hintAction(RotateDatabasePasswordAction::make() + ->hidden(fn () => $this->isReadOnly())) ->formatStateUsing(fn (Database $database) => $database->password), TextInput::make('remote') ->label(trans('admin/databasehost.table.remote')) @@ -83,6 +84,7 @@ public function table(Table $table): Table ViewAction::make() ->color('primary'), DeleteAction::make() + ->hidden(fn () => $this->isReadOnly()) ->successNotificationTitle(null) ->using(function (Database $database, DatabaseManagementService $service) { try { @@ -104,6 +106,7 @@ public function table(Table $table): Table ]) ->toolbarActions([ CreateAction::make() + ->hidden(fn () => $this->isReadOnly()) ->hiddenLabel() ->disabled(fn () => DatabaseHost::count() < 1) ->tooltip(fn () => DatabaseHost::count() < 1 ? trans('admin/server.no_db_hosts') : trans('admin/server.create_database')) From bcc3fa6218a96f6c42e476f0501cc3341d5abf28 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 17:36:44 +0100 Subject: [PATCH 14/20] trim docs to fit style --- app/Traits/Filament/ServerDetailTabs.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/Traits/Filament/ServerDetailTabs.php b/app/Traits/Filament/ServerDetailTabs.php index e29e3b4214..6513ce8c64 100644 --- a/app/Traits/Filament/ServerDetailTabs.php +++ b/app/Traits/Filament/ServerDetailTabs.php @@ -56,16 +56,12 @@ use Random\RandomException; /** - * Shared between EditServer and ViewServer. The schema is page-bound - * (it calls $this->getRecord(), $this->redirect(), $this->transferServer()), - * so it lives in a trait rather than a static like Egg. + * Page-bound tab schema shared between EditServer and ViewServer. */ trait ServerDetailTabs { /** - * Whether the edit-time scaffolding (materializing missing server - * variable rows) runs. ViewServer overrides this to false so that - * merely viewing the page never writes. + * Whether to materialize missing server variable rows. View pages disable it so viewing never writes. */ protected function materializesServerVariables(): bool { From 2a3d4ba01914b6fe6d2c57adf02e15b91bf101a4 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 18:13:38 +0100 Subject: [PATCH 15/20] test the server view page --- tests/Filament/Admin/ViewServerTest.php | 250 ++++++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 tests/Filament/Admin/ViewServerTest.php diff --git a/tests/Filament/Admin/ViewServerTest.php b/tests/Filament/Admin/ViewServerTest.php new file mode 100644 index 0000000000..44009c0e64 --- /dev/null +++ b/tests/Filament/Admin/ViewServerTest.php @@ -0,0 +1,250 @@ +create(['name' => $name, 'guard_name' => 'web']); + + foreach ($abilities as $ability) { + $role->givePermissionTo(Permission::findOrCreate($ability, 'web')); + } + + return $role; +} + +function serverAllocation(Server $server): Allocation +{ + return Allocation::factory()->create([ + 'node_id' => $server->node->getKey(), + 'server_id' => $server->getKey(), + ]); +} + +function assertServerViewIsReadOnly(Server $server, Allocation $allocation): void +{ + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $primaryBefore = $server->allocation_id; + $aliasBefore = $allocation->ip_alias; + $notesBefore = $allocation->notes; + + livewire(AllocationsRelationManager::class, [ + 'ownerRecord' => $server, + 'pageClass' => ViewServer::class, + ]) + ->assertActionHidden(TestAction::make('make-primary')->table($allocation)) + ->assertActionHidden(TestAction::make('lock')->table($allocation)) + ->assertActionHidden(TestAction::make(DissociateAction::class)->table($allocation)) + ->assertActionHidden(TestAction::make(CreateAction::class)->table()) + ->assertActionHidden(TestAction::make(AssociateAction::class)->table()) + ->assertActionHidden(TestAction::make(DissociateBulkAction::class)->table()->bulk()) + // primary is an IconColumn action, not hideable; callTableColumnAction ignores disabled(), so the + // in-closure isReadOnly guard is what must stop the write, and the unchanged allocation_id proves it + ->callTableColumnAction('primary', $allocation->getKey()) + ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias') + ->call('updateTableColumnState', 'notes', (string) $allocation->getKey(), 'hacked-notes'); + + livewire(DatabasesRelationManager::class, [ + 'ownerRecord' => $server, + 'pageClass' => ViewServer::class, + ]) + ->assertActionHidden(TestAction::make(CreateAction::class)->table()) + ->assertActionHidden(TestAction::make(DeleteAction::class)->table()); + + expect($server->refresh()->allocation_id)->toBe($primaryBefore) + ->and($allocation->refresh()->ip_alias)->toBe($aliasBefore) + ->and($allocation->notes)->toBe($notesBefore); +} + +// the rotate hintAction sits inside the database view modal, which standalone relation managers can't mount in +// tests, and isHidden() folds in the action's record-scoped authorize(); so resolve the action off the built +// form and evaluate just its view-page gate (the ->hidden closure wired to the manager isReadOnly state) +function databaseRotateHidden(Server $server, string $pageClass): bool +{ + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $manager = livewire(DatabasesRelationManager::class, [ + 'ownerRecord' => $server, + 'pageClass' => $pageClass, + ])->instance(); + + $password = collect($manager->form(Schema::make($manager))->getComponents()) + ->first(fn ($component) => $component instanceof TextInput && $component->getName() === 'password'); + + $rotate = collect($password->getHintActions()) + ->first(fn ($action) => $action->getName() === 'exclude_hint_rotate'); + + $gate = (new ReflectionProperty($rotate, 'isHidden'))->getValue($rotate); + + return (bool) $rotate->evaluate($gate); +} + +it('lets a user with view permission open the view page', function () { + [$user, $server] = generateTestAccount([]); + $user->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + $this->actingAs($user); + livewire(ViewServer::class, ['record' => $server->getKey()]) + ->assertSuccessful(); +}); + +it('forbids the view page without view permission', function () { + [$user, $server] = generateTestAccount([]); + $user->syncRoles(serverRole('Egg Viewer', [ + RolePermissionModels::Egg->view(), + ])); + + $this->actingAs($user); + livewire(ViewServer::class, ['record' => $server->getKey()]) + ->assertForbidden(); +}); + +it('forbids the edit page for a view-only user', function () { + [$user, $server] = generateTestAccount([]); + $user->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + $this->actingAs($user); + livewire(EditServer::class, ['record' => $server->getKey()]) + ->assertForbidden(); +}); + +it('does not materialize server variables when the view page is mounted', function () { + [$user, $server] = generateTestAccount([]); + $user->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + ServerVariable::query()->where('server_id', $server->getKey())->delete(); + $before = ServerVariable::query()->where('server_id', $server->getKey())->count(); + + $this->actingAs($user); + livewire(ViewServer::class, ['record' => $server->getKey()]) + ->assertSuccessful(); + + expect(ServerVariable::query()->where('server_id', $server->getKey())->count())->toBe($before); +}); + +it('hides the reinstall action on the view page but keeps it on edit', function () { + [$editor, $server] = generateTestAccount([]); + $editor->syncRoles(serverRole('Server Editor', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + RolePermissionModels::Server->update(), + ])); + + [$viewer] = generateTestAccount([]); + $viewer->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + $this->actingAs($editor); + livewire(EditServer::class, ['record' => $server->getKey()]) + ->assertActionVisible(TestAction::make('exclude_reinstall')->schemaComponent(true)); + + $this->actingAs($viewer); + livewire(ViewServer::class, ['record' => $server->getKey()]) + ->assertActionDoesNotExist(TestAction::make('exclude_reinstall')->schemaComponent(true)); +}); + +it('shows the view row action only when the user cannot edit', function () { + [$viewer, $server] = generateTestAccount([]); + $viewer->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + [$editor] = generateTestAccount([]); + $editor->syncRoles(serverRole('Server Editor', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + RolePermissionModels::Server->update(), + ])); + + // table action urls resolve against the current panel; the default is 'app', not 'admin' + Filament::setCurrentPanel(Filament::getPanel('admin')); + + $this->actingAs($viewer); + livewire(ListServers::class) + ->assertActionVisible(TestAction::make('view')->table($server)); + + $this->actingAs($editor); + livewire(ListServers::class) + ->assertActionHidden(TestAction::make('view')->table($server)); +}); + +it('keeps the server relation managers read-only on the view page for a view-only user', function () { + [$viewer, $server] = generateTestAccount([]); + $viewer->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + $this->actingAs($viewer); + assertServerViewIsReadOnly($server, serverAllocation($server)); +}); + +it('keeps the server relation managers read-only on the view page even for an update-capable user', function () { + [$editor, $server] = generateTestAccount([]); + $editor->syncRoles(serverRole('Server Editor', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + RolePermissionModels::Server->update(), + ])); + + $this->actingAs($editor); + assertServerViewIsReadOnly($server, serverAllocation($server)); +}); + +it('hides database password rotation behind the read-only gate on the view page', function () { + [$viewer, $server] = generateTestAccount([]); + $viewer->syncRoles(serverRole('Server Viewer', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + ])); + + $this->actingAs($viewer); + expect(databaseRotateHidden($server, ViewServer::class))->toBeTrue(); +}); + +it('leaves database password rotation available on the edit page', function () { + [$editor, $server] = generateTestAccount([]); + $editor->syncRoles(serverRole('Server Editor', [ + RolePermissionModels::Server->viewAny(), + RolePermissionModels::Server->view(), + RolePermissionModels::Server->update(), + ])); + + $this->actingAs($editor); + expect(databaseRotateHidden($server, EditServer::class))->toBeFalse(); +}); From b80854033c71d0967812fcdd833a0beaebc3581a Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sat, 13 Jun 2026 23:39:34 +0100 Subject: [PATCH 16/20] move node detail tabs onto the resource --- .../Admin/Resources/Nodes/NodeResource.php | 757 +++++++++++++++++ .../Admin/Resources/Nodes/Pages/EditNode.php | 27 +- .../Admin/Resources/Nodes/Pages/ViewNode.php | 34 +- app/Traits/Filament/NodeDetailTabs.php | 787 ------------------ 4 files changed, 795 insertions(+), 810 deletions(-) delete mode 100644 app/Traits/Filament/NodeDetailTabs.php diff --git a/app/Filament/Admin/Resources/Nodes/NodeResource.php b/app/Filament/Admin/Resources/Nodes/NodeResource.php index 7b413d8da3..9282628eb2 100644 --- a/app/Filament/Admin/Resources/Nodes/NodeResource.php +++ b/app/Filament/Admin/Resources/Nodes/NodeResource.php @@ -11,13 +11,45 @@ use App\Filament\Admin\Resources\Nodes\RelationManagers\AllocationsRelationManager; use App\Filament\Admin\Resources\Nodes\RelationManagers\ServersRelationManager; use App\Models\Node; +use App\Repositories\Daemon\DaemonSystemRepository; +use App\Services\Helpers\SoftwareVersionService; +use App\Services\Nodes\NodeAutoDeployService; +use App\Services\Nodes\NodeUpdateService; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; use BackedEnum; +use Exception; +use Filament\Actions\Action; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\Slider; +use Filament\Forms\Components\Slider\Enums\PipsMode; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\ToggleButtons; +use Filament\Infolists\Components\CodeEntry; +use Filament\Infolists\Components\TextEntry; +use Filament\Notifications\Notification; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\Resource; +use Filament\Schemas\Components\Actions; +use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\StateCasts\BooleanStateCast; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; +use Filament\Schemas\Components\View; +use Filament\Schemas\Schema; +use Filament\Support\Enums\Alignment; +use Filament\Support\RawJs; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\HtmlString; +use Phiki\Grammar\Grammar; class NodeResource extends Resource { @@ -81,4 +113,729 @@ public static function getEloquentQuery(): Builder return $query->whereIn('id', user()?->accessibleNodes()->pluck('id')); } + + /** @return Tab[] */ + public static function detailTabs(): array + { + return [ + Tab::make('overview') + ->label(trans('admin/node.tabs.overview')) + ->icon(TablerIcon::ChartAreaLineFilled) + ->columns([ + 'default' => 4, + 'sm' => 2, + 'md' => 4, + 'lg' => 4, + ]) + ->schema([ + Fieldset::make() + ->label(trans('admin/node.node_info')) + ->columns(4) + ->columnSpanFull() + ->schema([ + TextEntry::make('wings_version') + ->label(trans('admin/node.wings_version')) + ->state(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? trans('admin/node.unknown')) . ' ' . trans('admin/node.latest', ['version' => $versionService->latestWingsVersion()])), + TextEntry::make('cpu_threads') + ->label(trans('admin/node.cpu_threads')) + ->state(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0), + TextEntry::make('architecture') + ->label(trans('admin/node.architecture')) + ->state(fn (Node $node) => $node->systemInformation()['architecture'] ?? trans('admin/node.unknown')), + TextEntry::make('kernel') + ->label(trans('admin/node.kernel')) + ->state(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? trans('admin/node.unknown')), + ]), + View::make('filament.components.node-cpu-chart') + ->columnSpan([ + 'default' => 4, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]), + View::make('filament.components.node-memory-chart') + ->columnSpan([ + 'default' => 4, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]), + View::make('filament.components.node-storage-chart') + ->columnSpanFull(), + ]), + Tab::make('basic_settings') + ->label(trans('admin/node.tabs.basic_settings')) + ->icon(TablerIcon::Server) + ->schema([ + TextInput::make('fqdn') + ->columnSpan(2) + ->required() + ->autofocus() + ->live(debounce: 1500) + ->rules(Node::getRulesForField('fqdn')) + ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) + ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') + ->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null) + ->validationMessages([ + 'prohibited' => trans('admin/node.dns_error'), + ]) + ->prohibited(function ($state, Get $get) { + if (!$state) { + return true; + } + + if (is_ip($state)) { + return false; + } + + $ip = $get('ip'); + + return !is_ip($ip); + }) + ->hintColor(function ($state, Get $get) { + if (!$state) { + return null; + } + + if (is_ip($state)) { + if (request()->isSecure()) { + return 'warning'; + } + } else { + $ip = $get('ip'); + + return is_ip($ip) ? 'success' : 'danger'; + } + + return null; + }) + ->hint(function ($state, Get $get) { + if (!$state) { + return null; + } + + if (is_ip($state)) { + if (request()->isSecure()) { + return trans('admin/node.ssl_ip'); + } + } else { + $ip = $get('ip'); + + return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid'); + } + + return null; + }) + ->afterStateUpdated(function (Set $set, ?string $state) { + $set('ip', null); + + if (!$state) { + return; + } + + [$subdomain] = str($state)->explode('.', 2); + if (!is_numeric($subdomain)) { + $set('name', $subdomain); + } + + if (!is_ip($state)) { + $ip = get_ip_from_hostname($state); + if (is_ip($ip)) { + $set('ip', $ip); + } else { + $set('ip', null); + } + } + }) + ->maxLength(255), + Hidden::make('ip') + ->saved(false), + TextInput::make('daemon_connect') + ->columnSpan(1) + ->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) + ->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) + ->minValue(1) + ->maxValue(65535) + ->default(8080) + ->required() + ->integer(), + TextInput::make('daemon_listen') + ->columnSpan(1) + ->label(trans('admin/node.listen_port')) + ->helperText(trans('admin/node.listen_port_help')) + ->minValue(1) + ->maxValue(65535) + ->default(8080) + ->required() + ->integer() + ->visible(fn (Get $get) => $get('connection') === 'https_proxy'), + TextInput::make('name') + ->label(trans('admin/node.display_name')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->required() + ->maxLength(100), + ToggleButtons::make('connection') + ->label(trans('admin/node.ssl')) + ->columnSpan(2) + ->inline() + ->helperText(function () { + if (request()->isSecure()) { + return trans('admin/node.panel_on_ssl'); + } + + return null; + }) + ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure()) + ->options([ + 'http' => 'HTTP', + 'https' => 'HTTPS (SSL)', + 'https_proxy' => 'HTTPS with (reverse) proxy', + ]) + ->colors([ + 'http' => 'warning', + 'https' => 'success', + 'https_proxy' => 'success', + ]) + ->icons([ + 'http' => TablerIcon::LockOpenOff, + 'https' => TablerIcon::Lock, + 'https_proxy' => TablerIcon::ShieldLock, + ]) + ->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https')) + ->live() + ->dehydrated(false) + ->afterStateUpdated(function ($state, Set $set) { + $set('scheme', $state === 'http' ? 'http' : 'https'); + $set('behind_proxy', $state === 'https_proxy'); + + $set('daemon_connect', $state === 'https_proxy' ? 443 : 8080); + $set('daemon_listen', 8080); + }), + Hidden::make('scheme') + ->default(fn () => request()->isSecure() ? 'https' : 'http'), + Hidden::make('behind_proxy') + ->default(false), + ]), + Tab::make('advanced_settings') + ->label(trans('admin/node.tabs.advanced_settings')) + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 4, + 'lg' => 6, + ]) + ->icon(TablerIcon::ServerCog) + ->schema([ + TextInput::make('id') + ->label(trans('admin/node.node_id')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->disabled(), + TextInput::make('uuid') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->label(trans('admin/node.node_uuid')) + ->hintCopy() + ->disabled(), + TagsInput::make('tags') + ->label(trans('admin/node.tags')) + ->placeholder('') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]), + TextInput::make('upload_size') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->label(trans('admin/node.upload_limit')) + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.upload_limit_help')) + ->numeric() + ->required() + ->minValue(1) + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), + TextInput::make('daemon_base') + ->label(trans('admin/node.daemon_base')) + ->placeholder('/var/lib/pelican/volumes') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->required() + ->rule('regex:/^([\/][\d\w.\-\/]+)$/'), + TextInput::make('daemon_sftp') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 1, + ]) + ->label(trans('admin/node.sftp_port')) + ->minValue(1) + ->maxValue(65535) + ->default(2022) + ->required() + ->integer(), + TextInput::make('daemon_sftp_alias') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->label(trans('admin/node.sftp_alias')) + ->helperText(trans('admin/node.sftp_alias_help')), + ToggleButtons::make('public') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->label(trans('admin/node.use_for_deploy')) + ->inline() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.yes'), + 0 => trans('admin/node.no'), + ]) + ->colors([ + 1 => 'success', + 0 => 'danger', + ]), + ToggleButtons::make('maintenance_mode') + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->label(trans('admin/node.maintenance_mode')) + ->inline() + ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.maintenance_mode_help')) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.enabled'), + 0 => trans('admin/node.disabled'), + ]) + ->colors([ + 1 => 'danger', + 0 => 'success', + ]), + Grid::make() + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_mem') + ->dehydrated() + ->label(trans('admin/node.memory'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) + ->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('memory') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), + TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->label(trans('admin/node.memory_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(0), + TextInput::make('memory_overallocate') + ->dehydratedWhenHidden() + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->required() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + Grid::make() + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 1, + 'md' => 3, + 'lg' => 6, + ]) + ->schema([ + ToggleButtons::make('unlimited_disk') + ->dehydrated() + ->label(trans('admin/node.disk'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) + ->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('disk') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]), + TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/node.disk_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->numeric() + ->minValue(0), + TextInput::make('disk_overallocate') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->columnSpan([ + 'default' => 1, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->required() + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + Grid::make() + ->columns(6) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_cpu') + ->dehydrated() + ->label(trans('admin/node.cpu'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) + ->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0)) + ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/node.unlimited'), + 0 => trans('admin/node.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/node.cpu_limit'))->inlineLabel() + ->suffix('%') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + TextInput::make('cpu_overallocate') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/node.overallocate'))->inlineLabel() + ->columnSpan(2) + ->required() + ->numeric() + ->minValue(-1) + ->maxValue(100) + ->suffix('%'), + ]), + ]), + Tab::make('config_file') + ->label(trans('admin/node.tabs.config_file')) + ->icon(TablerIcon::Code) + ->hidden(fn (string $operation) => $operation === 'view') + ->schema([ + TextEntry::make('instructions') + ->label(trans('admin/node.instructions')) + ->columnSpanFull() + ->state(new HtmlString(trans('admin/node.instructions_help'))), + CodeEntry::make('config') + ->label('/etc/pelican/config.yml') + ->grammar(Grammar::Yaml) + ->state(fn (Node $node) => $node->getYamlConfiguration()) + ->copyable() + ->disabled() + ->columnSpanFull(), + Grid::make() + ->columns() + ->columnSpanFull() + ->schema([ + Actions::make([ + Action::make('exclude_autoDeploy') + ->label(trans('admin/node.auto_deploy')) + ->color('primary') + ->modalHeading(trans('admin/node.auto_deploy')) + ->icon(TablerIcon::Rocket) + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalFooterActionsAlignment(Alignment::Center) + ->schema([ + ToggleButtons::make('docker') + ->label(trans('admin/node.auto_label')) + ->live() + ->helperText(trans('admin/node.auto_question')) + ->inline() + ->default(false) + ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/node.standalone'), + 1 => trans('admin/node.docker'), + ]) + ->colors([ + 0 => 'primary', + 1 => 'success', + ]) + ->columnSpan(1), + Textarea::make('generatedToken') + ->label(trans('admin/node.auto_command')) + ->readOnly() + ->autosize() + ->hintCopy() + ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), + ]) + ->mountUsing(function (Schema $schema) { + $schema->fill(); + }) + ->hidden(fn (string $operation) => $operation === 'view'), + ])->fullWidth(), + Actions::make([ + Action::make('exclude_resetKey') + ->label(trans('admin/node.reset_token')) + ->color('danger') + ->requiresConfirmation() + ->modalHeading(trans('admin/node.reset_token')) + ->modalDescription(trans('admin/node.reset_help')) + ->action(function (Node $node, NodeUpdateService $service, EditNode|ViewNode $livewire) { + try { + $service->handle($node, [], true); + } catch (Exception) { + Notification::make() + ->title(trans('admin/node.error_connecting', ['node' => $node->name])) + ->body(trans('admin/node.error_connecting_description')) + ->color('warning') + ->icon(TablerIcon::Database) + ->warning() + ->send(); + + } + Notification::make()->success()->title(trans('admin/node.token_reset'))->send(); + $livewire->refreshFormData(['daemon_token_id', 'daemon_token']); + }) + ->hidden(fn (string $operation) => $operation === 'view'), + ])->fullWidth(), + ]), + ]), + Tab::make('diagnostics') + ->label(trans('admin/node.tabs.diagnostics')) + ->icon(TablerIcon::HeartSearch) + ->hidden(fn (string $operation) => $operation === 'view') + ->schema([ + Section::make('diag') + ->heading(trans('admin/node.tabs.diagnostics')) + ->columnSpanFull() + ->columns(4) + ->disabled(fn (Get $get) => $get('pulled')) + ->headerActions([ + Action::make('pull') + ->tooltip(trans('admin/node.diagnostics.pull')) + ->icon(TablerIcon::CloudDownload) + ->hidden(fn (Get $get) => $get('pulled')) + ->action(function (Get $get, Set $set, Node $node, DaemonSystemRepository $daemon) { + $includeEndpoints = $get('include_endpoints') ?? true; + $includeLogs = $get('include_logs') ?? true; + $logLines = $get('log_lines') ?? 200; + + try { + $response = $daemon->setNode($node)->getDiagnostics($logLines, $includeEndpoints, $includeLogs); + + if ($response->status() === 404) { + Notification::make() + ->title(trans('admin/node.diagnostics.404')) + ->warning() + ->send(); + + return; + } + + $set('pulled', true); + $set('uploaded', false); + $set('log', $response->body()); + + Notification::make() + ->title(trans('admin/node.diagnostics.logs_pulled')) + ->success() + ->send(); + } catch (ConnectionException $e) { + Notification::make() + ->title(trans('admin/node.error_connecting', ['node' => $node->name])) + ->body($e->getMessage()) + ->danger() + ->send(); + + } + }), + Action::make('upload') + ->tooltip(trans('admin/node.diagnostics.upload')) + ->visible(fn (Get $get) => $get('pulled') ?? false) + ->icon(TablerIcon::CloudUpload) + ->action(function (Get $get, Set $set) { + try { + $response = Http::asMultipart() + ->attach('c', $get('log')) + ->attach('e', '14d') + ->post('https://logs.pelican.dev'); + + if ($response->failed()) { + Notification::make() + ->title(trans('admin/node.diagnostics.upload_failed')) + ->body(fn () => $response->status() . ' - ' . $response->body()) + ->danger() + ->send(); + + return; + } + + $data = $response->json(); + $url = $data['url']; + + Notification::make() + ->title(trans('admin/node.diagnostics.logs_uploaded')) + ->body("{$url}") + ->success() + ->actions([ + Action::make('exclude_viewLogs') + ->label(trans('admin/node.diagnostics.view_logs')) + ->url($url) + ->openUrlInNewTab(true), + ]) + ->persistent() + ->send(); + $set('log', $url); + $set('pulled', false); + $set('uploaded', true); + + } catch (Exception $e) { + Notification::make() + ->title(trans('admin/node.diagnostics.upload_failed')) + ->body($e->getMessage()) + ->danger() + ->send(); + } + }), + Action::make('clear') + ->tooltip(trans('admin/node.diagnostics.clear')) + ->visible(fn (Get $get) => $get('pulled') ?? false) + ->icon(TablerIcon::Trash)->color('danger') + ->action(function (Get $get, Set $set, EditNode|ViewNode $livewire) { + $set('pulled', false); + $set('uploaded', false); + $set('log', null); + $livewire->refresh(); + } + ), + ]) + ->schema([ + ToggleButtons::make('include_endpoints') + ->hintIcon(TablerIcon::QuestionMark)->inline() + ->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint')) + ->formatStateUsing(fn () => 1) + ->boolean(), + ToggleButtons::make('include_logs') + ->live() + ->hintIcon(TablerIcon::QuestionMark)->inline() + ->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint')) + ->formatStateUsing(fn () => 1) + ->boolean(), + Slider::make('log_lines') + ->columnSpan(2) + ->hiddenLabel() + ->live() + ->tooltips(RawJs::make(<<<'JS' + `${$value} lines` + JS)) + ->visible(fn (Get $get) => $get('include_logs')) + ->range(minValue: 100, maxValue: 500) + ->pips(PipsMode::Steps, density: 10) + ->step(50) + ->formatStateUsing(fn () => 200) + ->fillTrack(), + Hidden::make('pulled'), + Hidden::make('uploaded'), + ]), + Textarea::make('log') + ->hiddenLabel() + ->columnSpanFull() + ->rows(35) + ->visible(fn (Get $get) => ($get('pulled') ?? false) || ($get('uploaded') ?? false)), + ]), + ]; + } } diff --git a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php index 853f689457..6ca1ab8fa1 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/EditNode.php @@ -6,11 +6,9 @@ use App\Filament\Admin\Resources\Nodes\NodeResource; use App\Models\Node; use App\Repositories\Daemon\DaemonSystemRepository; -use App\Services\Nodes\NodeUpdateService; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use App\Traits\Filament\NodeDetailTabs; use Filament\Actions\Action; use Filament\Actions\DeleteAction; use Filament\Notifications\Notification; @@ -26,18 +24,14 @@ class EditNode extends EditRecord use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; - use NodeDetailTabs; protected static string $resource = NodeResource::class; private DaemonSystemRepository $daemonSystemRepository; - private NodeUpdateService $nodeUpdateService; - - public function boot(DaemonSystemRepository $daemonSystemRepository, NodeUpdateService $nodeUpdateService): void + public function boot(DaemonSystemRepository $daemonSystemRepository): void { $this->daemonSystemRepository = $daemonSystemRepository; - $this->nodeUpdateService = $nodeUpdateService; } public function form(Schema $schema): Schema @@ -59,7 +53,24 @@ public function form(Schema $schema): Schema /** @return Tab[] */ protected function getDefaultTabs(): array { - return $this->detailTabs(); + return NodeResource::detailTabs(); + } + + protected function mutateFormDataBeforeFill(array $data): array + { + $node = Node::findOrFail($data['id']); + + if (!is_ip($node->fqdn)) { + $ip = get_ip_from_hostname($node->fqdn); + if ($ip) { + $data['dns'] = true; + $data['ip'] = $ip; + } else { + $data['dns'] = false; + } + } + + return $data; } protected function getFormActions(): array diff --git a/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php index 6eeabff798..6e6d9c4c6f 100644 --- a/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php +++ b/app/Filament/Admin/Resources/Nodes/Pages/ViewNode.php @@ -3,12 +3,10 @@ namespace App\Filament\Admin\Resources\Nodes\Pages; use App\Filament\Admin\Resources\Nodes\NodeResource; -use App\Repositories\Daemon\DaemonSystemRepository; -use App\Services\Nodes\NodeUpdateService; +use App\Models\Node; use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use App\Traits\Filament\NodeDetailTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\EditAction; @@ -22,20 +20,9 @@ class ViewNode extends ViewRecord use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; - use NodeDetailTabs; protected static string $resource = NodeResource::class; - private DaemonSystemRepository $daemonSystemRepository; - - private NodeUpdateService $nodeUpdateService; - - public function boot(DaemonSystemRepository $daemonSystemRepository, NodeUpdateService $nodeUpdateService): void - { - $this->daemonSystemRepository = $daemonSystemRepository; - $this->nodeUpdateService = $nodeUpdateService; - } - public function form(Schema $schema): Schema { return $schema->components([ @@ -55,7 +42,7 @@ public function form(Schema $schema): Schema /** @return Tab[] */ protected function getDefaultTabs(): array { - return $this->detailTabs(); + return NodeResource::detailTabs(); } /** @return array */ @@ -66,6 +53,23 @@ protected function getDefaultHeaderActions(): array ]; } + protected function mutateFormDataBeforeFill(array $data): array + { + $node = Node::findOrFail($data['id']); + + if (!is_ip($node->fqdn)) { + $ip = get_ip_from_hostname($node->fqdn); + if ($ip) { + $data['dns'] = true; + $data['ip'] = $ip; + } else { + $data['dns'] = false; + } + } + + return $data; + } + protected function getColumnSpan(): ?int { return null; diff --git a/app/Traits/Filament/NodeDetailTabs.php b/app/Traits/Filament/NodeDetailTabs.php deleted file mode 100644 index 2440fbc1fd..0000000000 --- a/app/Traits/Filament/NodeDetailTabs.php +++ /dev/null @@ -1,787 +0,0 @@ -label(trans('admin/node.tabs.overview')) - ->icon(TablerIcon::ChartAreaLineFilled) - ->columns([ - 'default' => 4, - 'sm' => 2, - 'md' => 4, - 'lg' => 4, - ]) - ->schema([ - Fieldset::make() - ->label(trans('admin/node.node_info')) - ->columns(4) - ->columnSpanFull() - ->schema([ - TextEntry::make('wings_version') - ->label(trans('admin/node.wings_version')) - ->state(fn (Node $node, SoftwareVersionService $versionService) => ($node->systemInformation()['version'] ?? trans('admin/node.unknown')) . ' ' . trans('admin/node.latest', ['version' => $versionService->latestWingsVersion()])), - TextEntry::make('cpu_threads') - ->label(trans('admin/node.cpu_threads')) - ->state(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0), - TextEntry::make('architecture') - ->label(trans('admin/node.architecture')) - ->state(fn (Node $node) => $node->systemInformation()['architecture'] ?? trans('admin/node.unknown')), - TextEntry::make('kernel') - ->label(trans('admin/node.kernel')) - ->state(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? trans('admin/node.unknown')), - ]), - View::make('filament.components.node-cpu-chart') - ->columnSpan([ - 'default' => 4, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]), - View::make('filament.components.node-memory-chart') - ->columnSpan([ - 'default' => 4, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]), - View::make('filament.components.node-storage-chart') - ->columnSpanFull(), - ]), - Tab::make('basic_settings') - ->label(trans('admin/node.tabs.basic_settings')) - ->icon(TablerIcon::Server) - ->schema([ - TextInput::make('fqdn') - ->columnSpan(2) - ->required() - ->autofocus() - ->live(debounce: 1500) - ->rules(Node::getRulesForField('fqdn')) - ->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain')) - ->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') - ->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null) - ->validationMessages([ - 'prohibited' => trans('admin/node.dns_error'), - ]) - ->prohibited(function ($state, Get $get) { - if (!$state) { - return true; - } - - if (is_ip($state)) { - return false; - } - - $ip = $get('ip'); - - return !is_ip($ip); - }) - ->hintColor(function ($state, Get $get) { - if (!$state) { - return null; - } - - if (is_ip($state)) { - if (request()->isSecure()) { - return 'warning'; - } - } else { - $ip = $get('ip'); - - return is_ip($ip) ? 'success' : 'danger'; - } - - return null; - }) - ->hint(function ($state, Get $get) { - if (!$state) { - return null; - } - - if (is_ip($state)) { - if (request()->isSecure()) { - return trans('admin/node.ssl_ip'); - } - } else { - $ip = $get('ip'); - - return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid'); - } - - return null; - }) - ->afterStateUpdated(function (Set $set, ?string $state) { - $set('ip', null); - - if (!$state) { - return; - } - - [$subdomain] = str($state)->explode('.', 2); - if (!is_numeric($subdomain)) { - $set('name', $subdomain); - } - - if (!is_ip($state)) { - $ip = get_ip_from_hostname($state); - if (is_ip($ip)) { - $set('ip', $ip); - } else { - $set('ip', null); - } - } - }) - ->maxLength(255), - Hidden::make('ip') - ->saved(false), - TextInput::make('daemon_connect') - ->columnSpan(1) - ->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port')) - ->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help')) - ->minValue(1) - ->maxValue(65535) - ->default(8080) - ->required() - ->integer(), - TextInput::make('daemon_listen') - ->columnSpan(1) - ->label(trans('admin/node.listen_port')) - ->helperText(trans('admin/node.listen_port_help')) - ->minValue(1) - ->maxValue(65535) - ->default(8080) - ->required() - ->integer() - ->visible(fn (Get $get) => $get('connection') === 'https_proxy'), - TextInput::make('name') - ->label(trans('admin/node.display_name')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->required() - ->maxLength(100), - ToggleButtons::make('connection') - ->label(trans('admin/node.ssl')) - ->columnSpan(2) - ->inline() - ->helperText(function () { - if (request()->isSecure()) { - return trans('admin/node.panel_on_ssl'); - } - - return null; - }) - ->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure()) - ->options([ - 'http' => 'HTTP', - 'https' => 'HTTPS (SSL)', - 'https_proxy' => 'HTTPS with (reverse) proxy', - ]) - ->colors([ - 'http' => 'warning', - 'https' => 'success', - 'https_proxy' => 'success', - ]) - ->icons([ - 'http' => TablerIcon::LockOpenOff, - 'https' => TablerIcon::Lock, - 'https_proxy' => TablerIcon::ShieldLock, - ]) - ->formatStateUsing(fn (Get $get) => $get('scheme') === 'http' ? 'http' : ($get('behind_proxy') ? 'https_proxy' : 'https')) - ->live() - ->dehydrated(false) - ->afterStateUpdated(function ($state, Set $set) { - $set('scheme', $state === 'http' ? 'http' : 'https'); - $set('behind_proxy', $state === 'https_proxy'); - - $set('daemon_connect', $state === 'https_proxy' ? 443 : 8080); - $set('daemon_listen', 8080); - }), - Hidden::make('scheme') - ->default(fn () => request()->isSecure() ? 'https' : 'http'), - Hidden::make('behind_proxy') - ->default(false), - ]), - Tab::make('advanced_settings') - ->label(trans('admin/node.tabs.advanced_settings')) - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 4, - 'lg' => 6, - ]) - ->icon(TablerIcon::ServerCog) - ->schema([ - TextInput::make('id') - ->label(trans('admin/node.node_id')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->disabled(), - TextInput::make('uuid') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->label(trans('admin/node.node_uuid')) - ->hintCopy() - ->disabled(), - TagsInput::make('tags') - ->label(trans('admin/node.tags')) - ->placeholder('') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]), - TextInput::make('upload_size') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->label(trans('admin/node.upload_limit')) - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.upload_limit_help')) - ->numeric() - ->required() - ->minValue(1) - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'), - TextInput::make('daemon_base') - ->label(trans('admin/node.daemon_base')) - ->placeholder('/var/lib/pelican/volumes') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.daemon_base_help')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->required() - ->rule('regex:/^([\/][\d\w.\-\/]+)$/'), - TextInput::make('daemon_sftp') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 1, - ]) - ->label(trans('admin/node.sftp_port')) - ->minValue(1) - ->maxValue(65535) - ->default(2022) - ->required() - ->integer(), - TextInput::make('daemon_sftp_alias') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->label(trans('admin/node.sftp_alias')) - ->helperText(trans('admin/node.sftp_alias_help')), - ToggleButtons::make('public') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->label(trans('admin/node.use_for_deploy')) - ->inline() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.yes'), - 0 => trans('admin/node.no'), - ]) - ->colors([ - 1 => 'success', - 0 => 'danger', - ]), - ToggleButtons::make('maintenance_mode') - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->label(trans('admin/node.maintenance_mode')) - ->inline() - ->hintIcon(TablerIcon::QuestionMark, trans('admin/node.maintenance_mode_help')) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.enabled'), - 0 => trans('admin/node.disabled'), - ]) - ->colors([ - 1 => 'danger', - 0 => 'success', - ]), - Grid::make() - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 3, - 'lg' => 6, - ]) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_mem') - ->dehydrated() - ->label(trans('admin/node.memory'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) - ->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('memory') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]), - TextInput::make('memory') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->label(trans('admin/node.memory_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(0), - TextInput::make('memory_overallocate') - ->dehydratedWhenHidden() - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->required() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - Grid::make() - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 1, - 'md' => 3, - 'lg' => 6, - ]) - ->schema([ - ToggleButtons::make('unlimited_disk') - ->dehydrated() - ->label(trans('admin/node.disk'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) - ->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('disk') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]), - TextInput::make('disk') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/node.disk_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->numeric() - ->minValue(0), - TextInput::make('disk_overallocate') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->columnSpan([ - 'default' => 1, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->required() - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - Grid::make() - ->columns(6) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_cpu') - ->dehydrated() - ->label(trans('admin/node.cpu'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) - ->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0)) - ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/node.unlimited'), - 0 => trans('admin/node.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - TextInput::make('cpu') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/node.cpu_limit'))->inlineLabel() - ->suffix('%') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - TextInput::make('cpu_overallocate') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/node.overallocate'))->inlineLabel() - ->columnSpan(2) - ->required() - ->numeric() - ->minValue(-1) - ->maxValue(100) - ->suffix('%'), - ]), - ]), - Tab::make('config_file') - ->label(trans('admin/node.tabs.config_file')) - ->icon(TablerIcon::Code) - ->hidden(fn (string $operation) => $operation === 'view') - ->schema([ - TextEntry::make('instructions') - ->label(trans('admin/node.instructions')) - ->columnSpanFull() - ->state(new HtmlString(trans('admin/node.instructions_help'))), - CodeEntry::make('config') - ->label('/etc/pelican/config.yml') - ->grammar(Grammar::Yaml) - ->state(fn (Node $node) => $node->getYamlConfiguration()) - ->copyable() - ->disabled() - ->columnSpanFull(), - Grid::make() - ->columns() - ->columnSpanFull() - ->schema([ - Actions::make([ - Action::make('exclude_autoDeploy') - ->label(trans('admin/node.auto_deploy')) - ->color('primary') - ->modalHeading(trans('admin/node.auto_deploy')) - ->icon(TablerIcon::Rocket) - ->modalSubmitAction(false) - ->modalCancelAction(false) - ->modalFooterActionsAlignment(Alignment::Center) - ->schema([ - ToggleButtons::make('docker') - ->label(trans('admin/node.auto_label')) - ->live() - ->helperText(trans('admin/node.auto_question')) - ->inline() - ->default(false) - ->afterStateUpdated(fn (bool $state, NodeAutoDeployService $service, Node $node, Set $set) => $set('generatedToken', $service->handle(request(), $node, $state))) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/node.standalone'), - 1 => trans('admin/node.docker'), - ]) - ->colors([ - 0 => 'primary', - 1 => 'success', - ]) - ->columnSpan(1), - Textarea::make('generatedToken') - ->label(trans('admin/node.auto_command')) - ->readOnly() - ->autosize() - ->hintCopy() - ->formatStateUsing(fn (NodeAutoDeployService $service, Node $node, Set $set, Get $get) => $set('generatedToken', $service->handle(request(), $node, $get('docker')))), - ]) - ->mountUsing(function (Schema $schema) { - $schema->fill(); - }) - ->hidden(fn (string $operation) => $operation === 'view'), - ])->fullWidth(), - Actions::make([ - Action::make('exclude_resetKey') - ->label(trans('admin/node.reset_token')) - ->color('danger') - ->requiresConfirmation() - ->modalHeading(trans('admin/node.reset_token')) - ->modalDescription(trans('admin/node.reset_help')) - ->action(function (Node $node) { - try { - $this->nodeUpdateService->handle($node, [], true); - } catch (Exception) { - Notification::make() - ->title(trans('admin/node.error_connecting', ['node' => $node->name])) - ->body(trans('admin/node.error_connecting_description')) - ->color('warning') - ->icon(TablerIcon::Database) - ->warning() - ->send(); - - } - Notification::make()->success()->title(trans('admin/node.token_reset'))->send(); - $this->fillForm(); - }) - ->hidden(fn (string $operation) => $operation === 'view'), - ])->fullWidth(), - ]), - ]), - Tab::make('diagnostics') - ->label(trans('admin/node.tabs.diagnostics')) - ->icon(TablerIcon::HeartSearch) - ->hidden(fn (string $operation) => $operation === 'view') - ->schema([ - Section::make('diag') - ->heading(trans('admin/node.tabs.diagnostics')) - ->columnSpanFull() - ->columns(4) - ->disabled(fn (Get $get) => $get('pulled')) - ->headerActions([ - Action::make('pull') - ->tooltip(trans('admin/node.diagnostics.pull')) - ->icon(TablerIcon::CloudDownload) - ->hidden(fn (Get $get) => $get('pulled')) - ->action(function (Get $get, Set $set, Node $node) { - $includeEndpoints = $get('include_endpoints') ?? true; - $includeLogs = $get('include_logs') ?? true; - $logLines = $get('log_lines') ?? 200; - - try { - $response = $this->daemonSystemRepository->setNode($node)->getDiagnostics($logLines, $includeEndpoints, $includeLogs); - - if ($response->status() === 404) { - Notification::make() - ->title(trans('admin/node.diagnostics.404')) - ->warning() - ->send(); - - return; - } - - $set('pulled', true); - $set('uploaded', false); - $set('log', $response->body()); - - Notification::make() - ->title(trans('admin/node.diagnostics.logs_pulled')) - ->success() - ->send(); - } catch (ConnectionException $e) { - Notification::make() - ->title(trans('admin/node.error_connecting', ['node' => $node->name])) - ->body($e->getMessage()) - ->danger() - ->send(); - - } - }), - Action::make('upload') - ->tooltip(trans('admin/node.diagnostics.upload')) - ->visible(fn (Get $get) => $get('pulled') ?? false) - ->icon(TablerIcon::CloudUpload) - ->action(function (Get $get, Set $set) { - try { - $response = Http::asMultipart() - ->attach('c', $get('log')) - ->attach('e', '14d') - ->post('https://logs.pelican.dev'); - - if ($response->failed()) { - Notification::make() - ->title(trans('admin/node.diagnostics.upload_failed')) - ->body(fn () => $response->status() . ' - ' . $response->body()) - ->danger() - ->send(); - - return; - } - - $data = $response->json(); - $url = $data['url']; - - Notification::make() - ->title(trans('admin/node.diagnostics.logs_uploaded')) - ->body("{$url}") - ->success() - ->actions([ - Action::make('exclude_viewLogs') - ->label(trans('admin/node.diagnostics.view_logs')) - ->url($url) - ->openUrlInNewTab(true), - ]) - ->persistent() - ->send(); - $set('log', $url); - $set('pulled', false); - $set('uploaded', true); - - } catch (Exception $e) { - Notification::make() - ->title(trans('admin/node.diagnostics.upload_failed')) - ->body($e->getMessage()) - ->danger() - ->send(); - } - }), - Action::make('clear') - ->tooltip(trans('admin/node.diagnostics.clear')) - ->visible(fn (Get $get) => $get('pulled') ?? false) - ->icon(TablerIcon::Trash)->color('danger') - ->action(function (Get $get, Set $set) { - $set('pulled', false); - $set('uploaded', false); - $set('log', null); - $this->refresh(); - } - ), - ]) - ->schema([ - ToggleButtons::make('include_endpoints') - ->hintIcon(TablerIcon::QuestionMark)->inline() - ->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint')) - ->formatStateUsing(fn () => 1) - ->boolean(), - ToggleButtons::make('include_logs') - ->live() - ->hintIcon(TablerIcon::QuestionMark)->inline() - ->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint')) - ->formatStateUsing(fn () => 1) - ->boolean(), - Slider::make('log_lines') - ->columnSpan(2) - ->hiddenLabel() - ->live() - ->tooltips(RawJs::make(<<<'JS' - `${$value} lines` - JS)) - ->visible(fn (Get $get) => $get('include_logs')) - ->range(minValue: 100, maxValue: 500) - ->pips(PipsMode::Steps, density: 10) - ->step(50) - ->formatStateUsing(fn () => 200) - ->fillTrack(), - Hidden::make('pulled'), - Hidden::make('uploaded'), - ]), - Textarea::make('log') - ->hiddenLabel() - ->columnSpanFull() - ->rows(35) - ->visible(fn (Get $get) => ($get('pulled') ?? false) || ($get('uploaded') ?? false)), - ]), - ]; - } - - protected function mutateFormDataBeforeFill(array $data): array - { - $node = Node::findOrFail($data['id']); - - if (!is_ip($node->fqdn)) { - $ip = get_ip_from_hostname($node->fqdn); - if ($ip) { - $data['dns'] = true; - $data['ip'] = $ip; - } else { - $data['dns'] = false; - } - } - - return $data; - } -} From db5c2b75e1a85e0acf9ee1b3fe81d5a1a1387e3f Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sun, 14 Jun 2026 03:17:17 +0100 Subject: [PATCH 17/20] move server detail tabs onto the resource --- .../Resources/Servers/Pages/EditServer.php | 4 +- .../Resources/Servers/Pages/ViewServer.php | 9 +- .../Resources/Servers/ServerResource.php | 924 +++++++++++++++++ app/Traits/Filament/ServerDetailTabs.php | 949 ------------------ 4 files changed, 926 insertions(+), 960 deletions(-) delete mode 100644 app/Traits/Filament/ServerDetailTabs.php diff --git a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php index 6b2e3fb276..c80a86de1d 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/EditServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/EditServer.php @@ -11,7 +11,6 @@ use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use App\Traits\Filament\ServerDetailTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Notifications\Notification; @@ -27,7 +26,6 @@ class EditServer extends EditRecord use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; - use ServerDetailTabs; protected static string $resource = ServerResource::class; @@ -62,7 +60,7 @@ public function form(Schema $schema): Schema */ protected function getDefaultTabs(): array { - return $this->detailTabs(); + return ServerResource::detailTabs(); } /** @return array */ diff --git a/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php b/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php index 65801ce27c..59f5f21deb 100644 --- a/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php +++ b/app/Filament/Admin/Resources/Servers/Pages/ViewServer.php @@ -6,7 +6,6 @@ use App\Traits\Filament\CanCustomizeHeaderActions; use App\Traits\Filament\CanCustomizeHeaderWidgets; use App\Traits\Filament\CanCustomizeTabs; -use App\Traits\Filament\ServerDetailTabs; use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\EditAction; @@ -21,7 +20,6 @@ class ViewServer extends ViewRecord use CanCustomizeHeaderActions; use CanCustomizeHeaderWidgets; use CanCustomizeTabs; - use ServerDetailTabs; protected static string $resource = ServerResource::class; @@ -49,7 +47,7 @@ public function form(Schema $schema): Schema */ protected function getDefaultTabs(): array { - return $this->detailTabs(); + return ServerResource::detailTabs(false); } /** @return array */ @@ -59,9 +57,4 @@ protected function getDefaultHeaderActions(): array EditAction::make(), ]; } - - protected function materializesServerVariables(): bool - { - return false; - } } diff --git a/app/Filament/Admin/Resources/Servers/ServerResource.php b/app/Filament/Admin/Resources/Servers/ServerResource.php index 58b885c806..6221b9e476 100644 --- a/app/Filament/Admin/Resources/Servers/ServerResource.php +++ b/app/Filament/Admin/Resources/Servers/ServerResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Admin\Resources\Servers; use App\Enums\CustomizationKey; +use App\Enums\SuspendAction; use App\Enums\TablerIcon; use App\Filament\Admin\Resources\Servers\Pages\CreateServer; use App\Filament\Admin\Resources\Servers\Pages\EditServer; @@ -10,18 +11,61 @@ use App\Filament\Admin\Resources\Servers\Pages\ViewServer; use App\Filament\Admin\Resources\Servers\RelationManagers\AllocationsRelationManager; use App\Filament\Admin\Resources\Servers\RelationManagers\DatabasesRelationManager; +use App\Filament\Components\Actions\DeleteIcon; +use App\Filament\Components\Actions\PreviewStartupAction; +use App\Filament\Components\Actions\UploadIcon; +use App\Filament\Components\Forms\Fields\MonacoEditor; +use App\Filament\Components\Forms\Fields\StartupVariable; +use App\Filament\Components\StateCasts\ServerConditionStateCast; +use App\Models\Allocation; +use App\Models\Backup; +use App\Models\Egg; use App\Models\Mount; use App\Models\Server; +use App\Models\User; +use App\Repositories\Daemon\DaemonServerRepository; +use App\Services\Eggs\EggChangerService; +use App\Services\Servers\RandomWordService; +use App\Services\Servers\ReinstallServerService; +use App\Services\Servers\SuspensionService; +use App\Services\Servers\ToggleInstallService; +use App\Services\Servers\TransferServerService; use App\Traits\Filament\CanCustomizePages; use App\Traits\Filament\CanCustomizeRelations; use BackedEnum; use Exception; +use Filament\Actions\Action; use Filament\Forms\Components\CheckboxList; +use Filament\Forms\Components\Hidden; +use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ToggleButtons; +use Filament\Notifications\Notification; use Filament\Resources\Pages\PageRegistration; use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\Resource; +use Filament\Schemas\Components\Actions; +use Filament\Schemas\Components\Component; +use Filament\Schemas\Components\Fieldset; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Image; +use Filament\Schemas\Components\StateCasts\BooleanStateCast; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Components\Text; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; +use Filament\Support\Enums\Alignment; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Support\Arr; +use Illuminate\Support\HtmlString; +use LogicException; +use Random\RandomException; class ServerResource extends Resource { @@ -111,4 +155,884 @@ public static function getEloquentQuery(): Builder return $query->whereIn('node_id', user()?->accessibleNodes()->pluck('id')); } + + /** + * @return Tab[] + * + * @throws RandomException + */ + public static function detailTabs(bool $materializeVariables = true): array + { + return [ + Tab::make('information') + ->label(trans('admin/server.tabs.information')) + ->icon(TablerIcon::InfoCircle) + ->schema([ + Grid::make() + ->columnStart(1) + ->schema([ + Image::make('', 'icon') + ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) + ->url(fn ($record) => $record->icon ?: $record->egg->icon) + ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) + ->imageSize(150) + ->columnSpanFull() + ->alignJustify(), + UploadIcon::make() + ->hidden(fn (string $operation) => $operation === 'view'), + DeleteIcon::make() + ->iconStoragePath(Server::getIconStoragePath()) + ->hidden(fn (string $operation) => $operation === 'view'), + ]), + Grid::make() + ->columns(3) + ->columnStart(2) + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 3, + 'lg' => 5, + ]) + ->schema([ + TextInput::make('name') + ->prefixIcon(TablerIcon::Server) + ->label(trans('admin/server.name')) + ->suffixAction(Action::make('hint_random') + ->tooltip('Random') + ->icon('tabler-dice-' . random_int(1, 6)) + ->action(function (Set $set, Get $get) { + $egg = Egg::find($get('egg_id')); + $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; + + $word = (new RandomWordService())->word(); + + $set('name', $prefix . $word); + }) + ->hidden(fn (string $operation) => $operation === 'view')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->required() + ->maxLength(255), + Select::make('owner_id') + ->prefixIcon(TablerIcon::User) + ->label(trans('admin/server.owner')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 2, + ]) + ->relationship('user', 'username') + ->searchable(['username', 'email']) + ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)") + ->preload() + ->required(), + ToggleButtons::make('condition') + ->label(trans('admin/server.server_status')) + ->formatStateUsing(fn (Server $server) => $server->condition) + ->options(fn ($state) => [$state->value => $state->getLabel()]) + ->colors(fn ($state) => [$state->value => $state->getColor()]) + ->icons(fn ($state) => [$state->value => $state->getIcon()]) + ->stateCast(new ServerConditionStateCast()) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 1, + 'lg' => 1, + ]) + ->hintAction( + Action::make('view_install_log') + ->label(trans('admin/server.view_install_log')) + //->visible(fn (Server $server) => $server->isFailedInstall()) + ->modalHeading('') + ->modalSubmitAction(false) + ->modalFooterActionsAlignment(Alignment::Right) + ->modalCancelActionLabel(trans('filament::components/modal.actions.close.label')) + ->schema([ + MonacoEditor::make('logs') + ->hiddenLabel() + ->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) { + try { + $logs = $serverRepository->setServer($server)->getInstallLogs(); + + return convert_to_utf8($logs); + } catch (ConnectionException) { + Notification::make() + ->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->body(trans('admin/server.notifications.log_failed')) + ->color('warning') + ->warning() + ->send(); + } catch (Exception) { + return ''; + } + + return ''; + }), + ]) + ->hidden(fn (string $operation) => $operation === 'view') + ), + ]), + Textarea::make('description') + ->label(trans('admin/server.description')) + ->columnSpanFull(), + TextInput::make('uuid') + ->label(trans('admin/server.uuid')) + ->copyable() + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly() + ->dehydrated(false), + TextInput::make('uuid_short') + ->label(trans('admin/server.short_uuid')) + ->copyable() + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly() + ->dehydrated(false), + TextInput::make('external_id') + ->label(trans('admin/server.external_id')) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->unique() + ->maxLength(255), + Select::make('node_id') + ->label(trans('admin/server.node')) + ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id'))) + ->columnSpan([ + 'default' => 2, + 'sm' => 1, + 'md' => 2, + 'lg' => 3, + ]) + ->disabled(), + ]), + Tab::make('environment_configuration') + ->label(trans('admin/server.tabs.environment_configuration')) + ->icon(TablerIcon::BrandDocker) + ->schema([ + Fieldset::make(trans('admin/server.resource_limits')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_cpu') + ->dehydrated() + ->label(trans('admin/server.cpu'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) + ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_cpu')) + ->label(trans('admin/server.cpu_limit'))->inlineLabel() + ->suffix('%') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.cpu_helper')) + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_mem') + ->dehydrated() + ->label(trans('admin/server.memory'))->inlineLabel()->inline() + ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) + ->formatStateUsing(fn (Get $get) => $get('memory') == 0) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_mem')) + ->label(trans('admin/server.memory_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.memory_helper')) + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('unlimited_disk') + ->dehydrated() + ->label(trans('admin/server.disk'))->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) + ->formatStateUsing(fn (Get $get) => $get('disk') == 0) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 1 => trans('admin/server.unlimited'), + 0 => trans('admin/server.limited'), + ]) + ->colors([ + 1 => 'primary', + 0 => 'warning', + ]) + ->columnSpan(2), + + TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => $get('unlimited_disk')) + ->label(trans('admin/server.disk_limit'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + ]), + + Fieldset::make(trans('admin/server.advanced_limits')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('cpu_pinning') + ->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline() + ->default(0) + ->afterStateUpdated(fn (Set $set) => $set('threads', [])) + ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) + ->live() + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.disabled'), + 1 => trans('admin/server.enabled'), + ]) + ->colors([ + 0 => 'success', + 1 => 'warning', + ]) + ->columnSpan(2), + + TagsInput::make('threads') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => !$get('cpu_pinning')) + ->label(trans('admin/server.threads'))->inlineLabel() + ->required(fn (Get $get) => $get('cpu_pinning')) + ->columnSpan(2) + ->separator() + ->splitKeys([',']) + ->placeholder(trans('admin/server.pin_help')), + ]), + ToggleButtons::make('swap_support') + ->live() + ->label(trans('admin/server.swap'))->inlineLabel()->inline() + ->columnSpan(2) + ->afterStateUpdated(function ($state, Set $set) { + $value = match ($state) { + 'unlimited' => -1, + 'disabled' => 0, + 'limited' => 128, + default => throw new LogicException('Invalid state') + }; + + $set('swap', $value); + }) + ->formatStateUsing(function (Get $get) { + return match (true) { + $get('swap') > 0 => 'limited', + $get('swap') == 0 => 'disabled', + $get('swap') < 0 => 'unlimited', + default => throw new LogicException('Invalid state') + }; + }) + ->options([ + 'unlimited' => trans('admin/server.unlimited'), + 'limited' => trans('admin/server.limited'), + 'disabled' => trans('admin/server.disabled'), + ]) + ->colors([ + 'unlimited' => 'primary', + 'limited' => 'warning', + 'disabled' => 'danger', + ]), + + TextInput::make('swap') + ->dehydratedWhenHidden() + ->hidden(fn (Get $get) => match ($get('swap_support')) { + 'disabled', 'unlimited', true => true, + default => false, + }) + ->label(trans('admin/server.swap'))->inlineLabel() + ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') + ->minValue(-1) + ->columnSpan(2) + ->required() + ->integer(), + ]), + + Hidden::make('io') + ->helperText('The IO performance relative to other running containers') + ->label('Block IO Proportion'), + + Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + ToggleButtons::make('oom_killer') + ->dehydrated() + ->label(trans('admin/server.oom')) + ->formatStateUsing(fn ($state) => $state) + ->inlineLabel() + ->inline() + ->columnSpan(2) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.disabled'), + 1 => trans('admin/server.enabled'), + ]) + ->colors([ + 0 => 'success', + 1 => 'danger', + ]), + ]), + ]), + + Fieldset::make(trans('admin/server.feature_limits')) + ->inlineLabel() + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + TextInput::make('allocation_limit') + ->label(trans('admin/server.allocations')) + ->suffixIcon(TablerIcon::Network) + ->required() + ->minValue(0) + ->numeric(), + TextInput::make('database_limit') + ->label(trans('admin/server.databases')) + ->suffixIcon(TablerIcon::Database) + ->required() + ->minValue(0) + ->numeric(), + TextInput::make('backup_limit') + ->label(trans('admin/server.backups')) + ->suffixIcon(TablerIcon::CopyCheck) + ->required() + ->minValue(0) + ->numeric(), + ]), + Fieldset::make(trans('admin/server.docker_settings')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 4, + ]) + ->schema([ + Select::make('select_image') + ->label(trans('admin/server.image_name')) + ->live() + ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) + ->options(function ($state, Get $get, Set $set) { + $egg = Egg::query()->find($get('egg_id')); + $images = $egg->docker_images ?? []; + + $currentImage = $get('image'); + if (!$currentImage && $images) { + $defaultImage = collect($images)->first(); + $set('image', $defaultImage); + $set('select_image', $defaultImage); + } + + return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image']; + }) + ->selectablePlaceholder(false) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 2, + ]), + + TextInput::make('image') + ->label(trans('admin/server.image')) + ->required() + ->afterStateUpdated(function ($state, Get $get, Set $set) { + $egg = Egg::query()->find($get('egg_id')); + $images = $egg->docker_images ?? []; + + if (in_array($state, $images)) { + $set('select_image', $state); + } else { + $set('select_image', 'ghcr.io/custom-image'); + } + }) + ->placeholder(trans('admin/server.image_placeholder')) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 2, + ]), + + KeyValue::make('docker_labels') + ->live() + ->label(trans('admin/server.container_labels')) + ->keyLabel(trans('admin/server.title')) + ->valueLabel(trans('admin/server.description')) + ->columnSpanFull(), + ]), + ]), + Tab::make('egg') + ->label(trans('admin/server.egg')) + ->icon(TablerIcon::Egg) + ->columns([ + 'default' => 1, + 'sm' => 3, + 'md' => 3, + 'lg' => 5, + ]) + ->schema([ + Select::make('egg_id') + ->disabled() + ->prefixIcon(TablerIcon::Egg) + ->columnSpan([ + 'default' => 6, + 'sm' => 3, + 'md' => 3, + 'lg' => 4, + ]) + ->relationship('egg', 'name') + ->label(trans('admin/server.name')) + ->searchable() + ->preload() + ->required() + ->hintAction( + Action::make('hint_change_egg') + ->label(trans('admin/server.change_egg')) + ->action(function (array $data, Server $server, EggChangerService $service, EditServer|ViewServer $livewire) { + $service->handle($server, $data['egg_id'], $data['keep_old_variables']); + + // Use redirect instead of fillForm to prevent server variables from duplicating + $livewire->redirect($livewire->getUrl(['record' => $server, 'tab' => 'egg::data::tab']), true); + }) + ->schema(fn (Server $server) => [ + Select::make('egg_id') + ->label(trans('admin/server.new_egg')) + ->prefixIcon(TablerIcon::Egg) + ->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name])) + ->searchable() + ->preload() + ->required(), + Toggle::make('keep_old_variables') + ->label(trans('admin/server.keep_old_variables')) + ->default(true), + ]) + ->hidden(fn (string $operation) => $operation === 'view') + ), + + ToggleButtons::make('skip_scripts') + ->label(trans('admin/server.install_script')) + ->inline() + ->columnSpan([ + 'default' => 6, + 'sm' => 1, + 'md' => 1, + 'lg' => 2, + ]) + ->stateCast(new BooleanStateCast(false, true)) + ->options([ + 0 => trans('admin/server.yes'), + 1 => trans('admin/server.skip'), + ]) + ->colors([ + 0 => 'primary', + 1 => 'danger', + ]) + ->icons([ + 0 => TablerIcon::Code, + 1 => TablerIcon::CodeOff, + ]) + ->required(), + + Hidden::make('previewing') + ->default(false), + + Select::make('select_startup') + ->label(trans('admin/server.startup_cmd')) + ->required() + ->live() + ->options(function (Get $get) { + $egg = Egg::find($get('egg_id')); + + return array_flip($egg->startup_commands ?? []) + ['custom' => 'Custom Startup']; + }) + ->formatStateUsing(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands) ? $server->startup : 'custom') + ->afterStateUpdated(function (Set $set, string $state) { + if ($state !== 'custom') { + $set('startup', $state); + } + $set('previewing', false); + }) + ->selectablePlaceholder(false) + ->columnSpanFull() + ->hintAction(PreviewStartupAction::make('hint_preview') + ->hidden(fn (string $operation) => $operation === 'view')), + + Textarea::make('startup') + ->hiddenLabel() + ->required() + ->live() + ->autosize() + ->afterStateUpdated(function ($state, Get $get, Set $set) { + $egg = Egg::find($get('egg_id')); + $startups = $egg->startup_commands ?? []; + + if (in_array($state, $startups)) { + $set('select_startup', $state); + } else { + $set('select_startup', 'custom'); + } + }) + ->placeholder(trans('admin/server.startup_placeholder')) + ->columnSpanFull(), + + Repeater::make('server_variables') + ->hiddenLabel() + ->relationship('serverVariables', function (Builder $query, EditServer|ViewServer $livewire) use ($materializeVariables) { + if ($materializeVariables) { + /** @var Server $server */ + $server = $livewire->getRecord(); + $server->ensureVariablesExist(); + } + + return $query->orderByPowerJoins('variable.sort'); + }) + ->grid() + ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { + $data['variable_value'] ??= ''; + + return $data; + }) + ->reorderable(false)->addable(false)->deletable(false) + ->schema([ + StartupVariable::make('variable_value') + ->fromRecord() + ->disabled(false), + ]) + ->columnSpan(6), + ]), + Tab::make('mounts') + ->label(trans('admin/server.mounts')) + ->icon(TablerIcon::LayersLinked) + ->schema(fn (Get $get) => [ + ServerResource::getMountCheckboxList($get), + ]), + Tab::make('actions') + ->label(trans('admin/server.actions')) + ->icon(TablerIcon::Settings) + ->hidden(fn (string $operation) => $operation === 'view') + ->schema([ + Fieldset::make(trans('admin/server.actions')) + ->columnSpanFull() + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 2, + 'lg' => 6, + ]) + ->schema([ + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_toggle_install') + ->label(trans('admin/server.toggle_install')) + ->disabled(fn (Server $server) => $server->isSuspended()) + ->modal(fn (Server $server) => $server->isFailedInstall()) + ->modalHeading(trans('admin/server.toggle_install_failed_header')) + ->modalDescription(trans('admin/server.toggle_install_failed_desc')) + ->modalSubmitActionLabel(trans('admin/server.reinstall')) + ->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) { + if ($server->isFailedInstall()) { + try { + $reinstallService->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.reinstall_started')) + ->success() + ->send(); + + } catch (Exception) { + Notification::make() + ->title(trans('admin/server.notifications.reinstall_failed')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->danger() + ->send(); + } + } else { + try { + $toggleService->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.install_toggled')) + ->success() + ->send(); + + } catch (Exception $exception) { + Notification::make() + ->title(trans('admin/server.notifications.install_toggle_failed')) + ->body($exception->getMessage()) + ->danger() + ->send(); + } + } + }), + ])->fullWidth(), + ToggleButtons::make('install_help') + ->hiddenLabel() + ->hint(trans('admin/server.toggle_install_help')), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_toggle_suspend') + ->label(trans('admin/server.suspend')) + ->color('warning') + ->hidden(fn (Server $server) => $server->isSuspended()) + ->action(function (SuspensionService $suspensionService, Server $server) { + try { + $suspensionService->handle($server, SuspendAction::Suspend); + + Notification::make() + ->success() + ->title(trans('admin/server.notifications.server_suspended')) + ->send(); + + } catch (Exception) { + Notification::make() + ->warning() + ->title(trans('admin/server.notifications.server_suspension')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->send(); + } + }), + Action::make('exclude_toggle_unsuspend') + ->label(trans('admin/server.unsuspend')) + ->color('success') + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->action(function (SuspensionService $suspensionService, Server $server) { + try { + $suspensionService->handle($server, SuspendAction::Unsuspend); + + Notification::make() + ->success() + ->title(trans('admin/server.notifications.server_unsuspended')) + ->send(); + + } catch (Exception) { + Notification::make() + ->warning() + ->title(trans('admin/server.notifications.server_suspension')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_suspend') + ->hiddenLabel() + ->hidden(fn (Server $server) => $server->isSuspended()) + ->hint(trans('admin/server.notifications.server_suspend_help')), + ToggleButtons::make('server_unsuspend') + ->hiddenLabel() + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->hint(trans('admin/server.notifications.server_unsuspend_help')), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_transfer') + ->label(trans('admin/server.transfer')) + ->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState()) + ->modalHeading(trans('admin/server.transfer')) + ->schema(self::transferServer()) + ->action(function (TransferServerService $transfer, Server $server, $data) { + try { + $selectedBackupUuids = Arr::get($data, 'backups', []); + $transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids); + + $server->backups + ->whereNotIn('uuid', $selectedBackupUuids) + ->where('disk', Backup::ADAPTER_DAEMON) + ->each(function ($backup) { + $backup->delete(); + }); + + Notification::make() + ->title(trans('admin/server.notifications.transfer_started')) + ->success() + ->send(); + } catch (Exception $exception) { + Notification::make() + ->title(trans('admin/server.notifications.transfer_failed')) + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_transfer') + ->hiddenLabel() + ->hint(new HtmlString(trans('admin/server.transfer_help'))), + ]), + Grid::make() + ->columnSpan(3) + ->schema([ + Actions::make([ + Action::make('exclude_reinstall') + ->label(trans('admin/server.reinstall')) + ->color('danger') + ->requiresConfirmation() + ->modalHeading(trans('admin/server.reinstall_modal_heading')) + ->modalDescription(trans('admin/server.reinstall_modal_description')) + ->disabled(fn (Server $server) => $server->isSuspended()) + ->action(function (ReinstallServerService $service, Server $server) { + try { + $service->handle($server); + + Notification::make() + ->title(trans('admin/server.notifications.reinstall_started')) + ->success() + ->send(); + } catch (Exception) { + Notification::make() + ->title(trans('admin/server.notifications.reinstall_failed')) + ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) + ->danger() + ->send(); + } + }), + ])->fullWidth(), + ToggleButtons::make('server_reinstall') + ->hiddenLabel() + ->hint(trans('admin/server.reinstall_help')), + ]), + ]), + ]), + ]; + } + + /** + * @return Component[] + * + * @throws Exception + */ + private static function transferServer(): array + { + return [ + Select::make('node_id') + ->label(trans('admin/server.node')) + ->prefixIcon(TablerIcon::Server2) + ->selectablePlaceholder(false) + ->default(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->first()?->id) + ->required() + ->live() + ->options(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->pluck('name', 'id')->all()), + Select::make('allocation_id') + ->label(trans('admin/server.primary_allocation')) + ->disabled(fn (Get $get, Server $server) => !$get('node_id') || !$server->allocation_id) + ->required(fn (Server $server) => $server->allocation_id) + ->prefixIcon(TablerIcon::Network) + ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) + ->searchable(['ip', 'port', 'ip_alias']) + ->placeholder(trans('admin/server.select_allocation')), + Select::make('allocation_additional') + ->label(trans('admin/server.additional_allocations')) + ->disabled(fn (Get $get, Server $server) => !$get('node_id') || $server->allocations->count() <= 1) + ->multiple() + ->minItems(fn (Select $select) => $select->getMaxItems()) + ->maxItems(fn (Select $select, Server $server) => $select->isDisabled() ? null : $server->allocations->count() - 1) + ->prefixIcon(TablerIcon::Network) + ->required(fn (Server $server) => $server->allocations->count() > 1) + ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) + ->searchable(['ip', 'port', 'ip_alias']) + ->placeholder(trans('admin/server.select_additional')), + Grid::make() + ->columnSpanFull() + ->schema([ + CheckboxList::make('backups') + ->label(trans('admin/server.backups')) + ->bulkToggleable() + ->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name])) + ->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)), + Text::make('backup_helper') + ->columnSpanFull() + ->content(trans('admin/server.warning_backups')), + ]) + ->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0), + ]; + } } diff --git a/app/Traits/Filament/ServerDetailTabs.php b/app/Traits/Filament/ServerDetailTabs.php deleted file mode 100644 index 6513ce8c64..0000000000 --- a/app/Traits/Filament/ServerDetailTabs.php +++ /dev/null @@ -1,949 +0,0 @@ -label(trans('admin/server.tabs.information')) - ->icon(TablerIcon::InfoCircle) - ->schema([ - Grid::make() - ->columnStart(1) - ->schema([ - Image::make('', 'icon') - ->hidden(fn ($record) => !$record->icon && !$record->egg->icon) - ->url(fn ($record) => $record->icon ?: $record->egg->icon) - ->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip')) - ->imageSize(150) - ->columnSpanFull() - ->alignJustify(), - UploadIcon::make() - ->hidden(fn (string $operation) => $operation === 'view'), - DeleteIcon::make() - ->iconStoragePath(Server::getIconStoragePath()) - ->hidden(fn (string $operation) => $operation === 'view'), - ]), - Grid::make() - ->columns(3) - ->columnStart(2) - ->columnSpan([ - 'default' => 2, - 'sm' => 2, - 'md' => 3, - 'lg' => 5, - ]) - ->schema([ - TextInput::make('name') - ->prefixIcon(TablerIcon::Server) - ->label(trans('admin/server.name')) - ->suffixAction(Action::make('hint_random') - ->tooltip('Random') - ->icon('tabler-dice-' . random_int(1, 6)) - ->action(function (Set $set, Get $get) { - $egg = Egg::find($get('egg_id')); - $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; - - $word = (new RandomWordService())->word(); - - $set('name', $prefix . $word); - }) - ->hidden(fn (string $operation) => $operation === 'view')) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->required() - ->maxLength(255), - Select::make('owner_id') - ->prefixIcon(TablerIcon::User) - ->label(trans('admin/server.owner')) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 2, - ]) - ->relationship('user', 'username') - ->searchable(['username', 'email']) - ->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)") - ->preload() - ->required(), - ToggleButtons::make('condition') - ->label(trans('admin/server.server_status')) - ->formatStateUsing(fn (Server $server) => $server->condition) - ->options(fn ($state) => [$state->value => $state->getLabel()]) - ->colors(fn ($state) => [$state->value => $state->getColor()]) - ->icons(fn ($state) => [$state->value => $state->getIcon()]) - ->stateCast(new ServerConditionStateCast()) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 1, - 'lg' => 1, - ]) - ->hintAction( - Action::make('view_install_log') - ->label(trans('admin/server.view_install_log')) - //->visible(fn (Server $server) => $server->isFailedInstall()) - ->modalHeading('') - ->modalSubmitAction(false) - ->modalFooterActionsAlignment(Alignment::Right) - ->modalCancelActionLabel(trans('filament::components/modal.actions.close.label')) - ->schema([ - MonacoEditor::make('logs') - ->hiddenLabel() - ->formatStateUsing(function (Server $server, DaemonServerRepository $serverRepository) { - try { - $logs = $serverRepository->setServer($server)->getInstallLogs(); - - return convert_to_utf8($logs); - } catch (ConnectionException) { - Notification::make() - ->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->body(trans('admin/server.notifications.log_failed')) - ->color('warning') - ->warning() - ->send(); - } catch (Exception) { - return ''; - } - - return ''; - }), - ]) - ->hidden(fn (string $operation) => $operation === 'view') - ), - ]), - Textarea::make('description') - ->label(trans('admin/server.description')) - ->columnSpanFull(), - TextInput::make('uuid') - ->label(trans('admin/server.uuid')) - ->copyable() - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->readOnly() - ->dehydrated(false), - TextInput::make('uuid_short') - ->label(trans('admin/server.short_uuid')) - ->copyable() - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->readOnly() - ->dehydrated(false), - TextInput::make('external_id') - ->label(trans('admin/server.external_id')) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->unique() - ->maxLength(255), - Select::make('node_id') - ->label(trans('admin/server.node')) - ->relationship('node', 'name', fn (Builder $query) => $query->whereIn('id', user()?->accessibleNodes()->pluck('id'))) - ->columnSpan([ - 'default' => 2, - 'sm' => 1, - 'md' => 2, - 'lg' => 3, - ]) - ->disabled(), - ]), - Tab::make('environment_configuration') - ->label(trans('admin/server.tabs.environment_configuration')) - ->icon(TablerIcon::BrandDocker) - ->schema([ - Fieldset::make(trans('admin/server.resource_limits')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_cpu') - ->dehydrated() - ->label(trans('admin/server.cpu'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('cpu', 0)) - ->formatStateUsing(fn (Get $get) => $get('cpu') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('cpu') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_cpu')) - ->label(trans('admin/server.cpu_limit'))->inlineLabel() - ->suffix('%') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.cpu_helper')) - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_mem') - ->dehydrated() - ->label(trans('admin/server.memory'))->inlineLabel()->inline() - ->afterStateUpdated(fn (Set $set) => $set('memory', 0)) - ->formatStateUsing(fn (Get $get) => $get('memory') == 0) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('memory') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_mem')) - ->label(trans('admin/server.memory_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->hintIcon(TablerIcon::QuestionMark, trans('admin/server.memory_helper')) - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('unlimited_disk') - ->dehydrated() - ->label(trans('admin/server.disk'))->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Set $set) => $set('disk', 0)) - ->formatStateUsing(fn (Get $get) => $get('disk') == 0) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 1 => trans('admin/server.unlimited'), - 0 => trans('admin/server.limited'), - ]) - ->colors([ - 1 => 'primary', - 0 => 'warning', - ]) - ->columnSpan(2), - - TextInput::make('disk') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => $get('unlimited_disk')) - ->label(trans('admin/server.disk_limit'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - ]), - - Fieldset::make(trans('admin/server.advanced_limits')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('cpu_pinning') - ->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline() - ->default(0) - ->afterStateUpdated(fn (Set $set) => $set('threads', [])) - ->formatStateUsing(fn (Get $get) => !empty($get('threads'))) - ->live() - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.disabled'), - 1 => trans('admin/server.enabled'), - ]) - ->colors([ - 0 => 'success', - 1 => 'warning', - ]) - ->columnSpan(2), - - TagsInput::make('threads') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => !$get('cpu_pinning')) - ->label(trans('admin/server.threads'))->inlineLabel() - ->required(fn (Get $get) => $get('cpu_pinning')) - ->columnSpan(2) - ->separator() - ->splitKeys([',']) - ->placeholder(trans('admin/server.pin_help')), - ]), - ToggleButtons::make('swap_support') - ->live() - ->label(trans('admin/server.swap'))->inlineLabel()->inline() - ->columnSpan(2) - ->afterStateUpdated(function ($state, Set $set) { - $value = match ($state) { - 'unlimited' => -1, - 'disabled' => 0, - 'limited' => 128, - default => throw new LogicException('Invalid state') - }; - - $set('swap', $value); - }) - ->formatStateUsing(function (Get $get) { - return match (true) { - $get('swap') > 0 => 'limited', - $get('swap') == 0 => 'disabled', - $get('swap') < 0 => 'unlimited', - default => throw new LogicException('Invalid state') - }; - }) - ->options([ - 'unlimited' => trans('admin/server.unlimited'), - 'limited' => trans('admin/server.limited'), - 'disabled' => trans('admin/server.disabled'), - ]) - ->colors([ - 'unlimited' => 'primary', - 'limited' => 'warning', - 'disabled' => 'danger', - ]), - - TextInput::make('swap') - ->dehydratedWhenHidden() - ->hidden(fn (Get $get) => match ($get('swap_support')) { - 'disabled', 'unlimited', true => true, - default => false, - }) - ->label(trans('admin/server.swap'))->inlineLabel() - ->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB') - ->minValue(-1) - ->columnSpan(2) - ->required() - ->integer(), - ]), - - Hidden::make('io') - ->helperText('The IO performance relative to other running containers') - ->label('Block IO Proportion'), - - Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - ToggleButtons::make('oom_killer') - ->dehydrated() - ->label(trans('admin/server.oom')) - ->formatStateUsing(fn ($state) => $state) - ->inlineLabel() - ->inline() - ->columnSpan(2) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.disabled'), - 1 => trans('admin/server.enabled'), - ]) - ->colors([ - 0 => 'success', - 1 => 'danger', - ]), - ]), - ]), - - Fieldset::make(trans('admin/server.feature_limits')) - ->inlineLabel() - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - TextInput::make('allocation_limit') - ->label(trans('admin/server.allocations')) - ->suffixIcon(TablerIcon::Network) - ->required() - ->minValue(0) - ->numeric(), - TextInput::make('database_limit') - ->label(trans('admin/server.databases')) - ->suffixIcon(TablerIcon::Database) - ->required() - ->minValue(0) - ->numeric(), - TextInput::make('backup_limit') - ->label(trans('admin/server.backups')) - ->suffixIcon(TablerIcon::CopyCheck) - ->required() - ->minValue(0) - ->numeric(), - ]), - Fieldset::make(trans('admin/server.docker_settings')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 4, - ]) - ->schema([ - Select::make('select_image') - ->label(trans('admin/server.image_name')) - ->live() - ->afterStateUpdated(fn (Set $set, $state) => $set('image', $state)) - ->options(function ($state, Get $get, Set $set) { - $egg = Egg::query()->find($get('egg_id')); - $images = $egg->docker_images ?? []; - - $currentImage = $get('image'); - if (!$currentImage && $images) { - $defaultImage = collect($images)->first(); - $set('image', $defaultImage); - $set('select_image', $defaultImage); - } - - return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image']; - }) - ->selectablePlaceholder(false) - ->columnSpan([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 2, - ]), - - TextInput::make('image') - ->label(trans('admin/server.image')) - ->required() - ->afterStateUpdated(function ($state, Get $get, Set $set) { - $egg = Egg::query()->find($get('egg_id')); - $images = $egg->docker_images ?? []; - - if (in_array($state, $images)) { - $set('select_image', $state); - } else { - $set('select_image', 'ghcr.io/custom-image'); - } - }) - ->placeholder(trans('admin/server.image_placeholder')) - ->columnSpan([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 2, - ]), - - KeyValue::make('docker_labels') - ->live() - ->label(trans('admin/server.container_labels')) - ->keyLabel(trans('admin/server.title')) - ->valueLabel(trans('admin/server.description')) - ->columnSpanFull(), - ]), - ]), - Tab::make('egg') - ->label(trans('admin/server.egg')) - ->icon(TablerIcon::Egg) - ->columns([ - 'default' => 1, - 'sm' => 3, - 'md' => 3, - 'lg' => 5, - ]) - ->schema([ - Select::make('egg_id') - ->disabled() - ->prefixIcon(TablerIcon::Egg) - ->columnSpan([ - 'default' => 6, - 'sm' => 3, - 'md' => 3, - 'lg' => 4, - ]) - ->relationship('egg', 'name') - ->label(trans('admin/server.name')) - ->searchable() - ->preload() - ->required() - ->hintAction( - Action::make('hint_change_egg') - ->label(trans('admin/server.change_egg')) - ->action(function (array $data, Server $server, EggChangerService $service) { - $service->handle($server, $data['egg_id'], $data['keep_old_variables']); - - // Use redirect instead of fillForm to prevent server variables from duplicating - $this->redirect($this->getUrl(['record' => $server, 'tab' => 'egg::data::tab']), true); - }) - ->schema(fn (Server $server) => [ - Select::make('egg_id') - ->label(trans('admin/server.new_egg')) - ->prefixIcon(TablerIcon::Egg) - ->options(fn () => Egg::all()->filter(fn (Egg $egg) => $egg->id !== $server->egg->id)->mapWithKeys(fn (Egg $egg) => [$egg->id => $egg->name])) - ->searchable() - ->preload() - ->required(), - Toggle::make('keep_old_variables') - ->label(trans('admin/server.keep_old_variables')) - ->default(true), - ]) - ->hidden(fn (string $operation) => $operation === 'view') - ), - - ToggleButtons::make('skip_scripts') - ->label(trans('admin/server.install_script')) - ->inline() - ->columnSpan([ - 'default' => 6, - 'sm' => 1, - 'md' => 1, - 'lg' => 2, - ]) - ->stateCast(new BooleanStateCast(false, true)) - ->options([ - 0 => trans('admin/server.yes'), - 1 => trans('admin/server.skip'), - ]) - ->colors([ - 0 => 'primary', - 1 => 'danger', - ]) - ->icons([ - 0 => TablerIcon::Code, - 1 => TablerIcon::CodeOff, - ]) - ->required(), - - Hidden::make('previewing') - ->default(false), - - Select::make('select_startup') - ->label(trans('admin/server.startup_cmd')) - ->required() - ->live() - ->options(function (Get $get) { - $egg = Egg::find($get('egg_id')); - - return array_flip($egg->startup_commands ?? []) + ['custom' => 'Custom Startup']; - }) - ->formatStateUsing(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands) ? $server->startup : 'custom') - ->afterStateUpdated(function (Set $set, string $state) { - if ($state !== 'custom') { - $set('startup', $state); - } - $set('previewing', false); - }) - ->selectablePlaceholder(false) - ->columnSpanFull() - ->hintAction(PreviewStartupAction::make('hint_preview') - ->hidden(fn (string $operation) => $operation === 'view')), - - Textarea::make('startup') - ->hiddenLabel() - ->required() - ->live() - ->autosize() - ->afterStateUpdated(function ($state, Get $get, Set $set) { - $egg = Egg::find($get('egg_id')); - $startups = $egg->startup_commands ?? []; - - if (in_array($state, $startups)) { - $set('select_startup', $state); - } else { - $set('select_startup', 'custom'); - } - }) - ->placeholder(trans('admin/server.startup_placeholder')) - ->columnSpanFull(), - - Repeater::make('server_variables') - ->hiddenLabel() - ->relationship('serverVariables', function (Builder $query) { - /** @var Server $server */ - $server = $this->getRecord(); - - if ($this->materializesServerVariables()) { - $server->ensureVariablesExist(); - } - - return $query->orderByPowerJoins('variable.sort'); - }) - ->grid() - ->mutateRelationshipDataBeforeSaveUsing(function (array $data): array { - $data['variable_value'] ??= ''; - - return $data; - }) - ->reorderable(false)->addable(false)->deletable(false) - ->schema([ - StartupVariable::make('variable_value') - ->fromRecord() - ->disabled(false), - ]) - ->columnSpan(6), - ]), - Tab::make('mounts') - ->label(trans('admin/server.mounts')) - ->icon(TablerIcon::LayersLinked) - ->schema(fn (Get $get) => [ - ServerResource::getMountCheckboxList($get), - ]), - Tab::make('actions') - ->label(trans('admin/server.actions')) - ->icon(TablerIcon::Settings) - ->hidden(fn (string $operation) => $operation === 'view') - ->schema([ - Fieldset::make(trans('admin/server.actions')) - ->columnSpanFull() - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 2, - 'lg' => 6, - ]) - ->schema([ - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_toggle_install') - ->label(trans('admin/server.toggle_install')) - ->disabled(fn (Server $server) => $server->isSuspended()) - ->modal(fn (Server $server) => $server->isFailedInstall()) - ->modalHeading(trans('admin/server.toggle_install_failed_header')) - ->modalDescription(trans('admin/server.toggle_install_failed_desc')) - ->modalSubmitActionLabel(trans('admin/server.reinstall')) - ->action(function (ToggleInstallService $toggleService, ReinstallServerService $reinstallService, Server $server) { - if ($server->isFailedInstall()) { - try { - $reinstallService->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.reinstall_started')) - ->success() - ->send(); - - } catch (Exception) { - Notification::make() - ->title(trans('admin/server.notifications.reinstall_failed')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->danger() - ->send(); - } - } else { - try { - $toggleService->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.install_toggled')) - ->success() - ->send(); - - } catch (Exception $exception) { - Notification::make() - ->title(trans('admin/server.notifications.install_toggle_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - } - }), - ])->fullWidth(), - ToggleButtons::make('install_help') - ->hiddenLabel() - ->hint(trans('admin/server.toggle_install_help')), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_toggle_suspend') - ->label(trans('admin/server.suspend')) - ->color('warning') - ->hidden(fn (Server $server) => $server->isSuspended()) - ->action(function (SuspensionService $suspensionService, Server $server) { - try { - $suspensionService->handle($server, SuspendAction::Suspend); - - Notification::make() - ->success() - ->title(trans('admin/server.notifications.server_suspended')) - ->send(); - - } catch (Exception) { - Notification::make() - ->warning() - ->title(trans('admin/server.notifications.server_suspension')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->send(); - } - }), - Action::make('exclude_toggle_unsuspend') - ->label(trans('admin/server.unsuspend')) - ->color('success') - ->hidden(fn (Server $server) => !$server->isSuspended()) - ->action(function (SuspensionService $suspensionService, Server $server) { - try { - $suspensionService->handle($server, SuspendAction::Unsuspend); - - Notification::make() - ->success() - ->title(trans('admin/server.notifications.server_unsuspended')) - ->send(); - - } catch (Exception) { - Notification::make() - ->warning() - ->title(trans('admin/server.notifications.server_suspension')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_suspend') - ->hiddenLabel() - ->hidden(fn (Server $server) => $server->isSuspended()) - ->hint(trans('admin/server.notifications.server_suspend_help')), - ToggleButtons::make('server_unsuspend') - ->hiddenLabel() - ->hidden(fn (Server $server) => !$server->isSuspended()) - ->hint(trans('admin/server.notifications.server_unsuspend_help')), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_transfer') - ->label(trans('admin/server.transfer')) - ->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState()) - ->modalHeading(trans('admin/server.transfer')) - ->schema($this->transferServer()) - ->action(function (TransferServerService $transfer, Server $server, $data) { - try { - $selectedBackupUuids = Arr::get($data, 'backups', []); - $transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids); - - $server->backups - ->whereNotIn('uuid', $selectedBackupUuids) - ->where('disk', Backup::ADAPTER_DAEMON) - ->each(function ($backup) { - $backup->delete(); - }); - - Notification::make() - ->title(trans('admin/server.notifications.transfer_started')) - ->success() - ->send(); - } catch (Exception $exception) { - Notification::make() - ->title(trans('admin/server.notifications.transfer_failed')) - ->body($exception->getMessage()) - ->danger() - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_transfer') - ->hiddenLabel() - ->hint(new HtmlString(trans('admin/server.transfer_help'))), - ]), - Grid::make() - ->columnSpan(3) - ->schema([ - Actions::make([ - Action::make('exclude_reinstall') - ->label(trans('admin/server.reinstall')) - ->color('danger') - ->requiresConfirmation() - ->modalHeading(trans('admin/server.reinstall_modal_heading')) - ->modalDescription(trans('admin/server.reinstall_modal_description')) - ->disabled(fn (Server $server) => $server->isSuspended()) - ->action(function (ReinstallServerService $service, Server $server) { - try { - $service->handle($server); - - Notification::make() - ->title(trans('admin/server.notifications.reinstall_started')) - ->success() - ->send(); - } catch (Exception) { - Notification::make() - ->title(trans('admin/server.notifications.reinstall_failed')) - ->body(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name])) - ->danger() - ->send(); - } - }), - ])->fullWidth(), - ToggleButtons::make('server_reinstall') - ->hiddenLabel() - ->hint(trans('admin/server.reinstall_help')), - ]), - ]), - ]), - ]; - } - - /** @return Component[] - * @throws Exception - */ - protected function transferServer(): array - { - return [ - Select::make('node_id') - ->label(trans('admin/server.node')) - ->prefixIcon(TablerIcon::Server2) - ->selectablePlaceholder(false) - ->default(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->first()?->id) - ->required() - ->live() - ->options(fn (Server $server) => user()?->accessibleNodes()->whereNot('id', $server->node->id)->pluck('name', 'id')->all()), - Select::make('allocation_id') - ->label(trans('admin/server.primary_allocation')) - ->disabled(fn (Get $get, Server $server) => !$get('node_id') || !$server->allocation_id) - ->required(fn (Server $server) => $server->allocation_id) - ->prefixIcon(TablerIcon::Network) - ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) - ->searchable(['ip', 'port', 'ip_alias']) - ->placeholder(trans('admin/server.select_allocation')), - Select::make('allocation_additional') - ->label(trans('admin/server.additional_allocations')) - ->disabled(fn (Get $get, Server $server) => !$get('node_id') || $server->allocations->count() <= 1) - ->multiple() - ->minItems(fn (Select $select) => $select->getMaxItems()) - ->maxItems(fn (Select $select, Server $server) => $select->isDisabled() ? null : $server->allocations->count() - 1) - ->prefixIcon(TablerIcon::Network) - ->required(fn (Server $server) => $server->allocations->count() > 1) - ->options(fn (Get $get) => Allocation::where('node_id', $get('node_id'))->whereNull('server_id')->when($get('allocation_id'), fn ($query) => $query->whereNot('id', $get('allocation_id')))->get()->mapWithKeys(fn (Allocation $allocation) => [$allocation->id => $allocation->address])) - ->searchable(['ip', 'port', 'ip_alias']) - ->placeholder(trans('admin/server.select_additional')), - Grid::make() - ->columnSpanFull() - ->schema([ - CheckboxList::make('backups') - ->label(trans('admin/server.backups')) - ->bulkToggleable() - ->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name])) - ->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)), - Text::make('backup_helper') - ->columnSpanFull() - ->content(trans('admin/server.warning_backups')), - ]) - ->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0), - ]; - } -} From d0952e76e358cd1a167cf07ff68385bcb5cf7e49 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sun, 14 Jun 2026 04:06:08 +0100 Subject: [PATCH 18/20] reset filament panel state between view tests --- tests/Filament/Admin/ViewEggTest.php | 2 ++ tests/Filament/Admin/ViewNodeTest.php | 2 ++ tests/Filament/Admin/ViewServerTest.php | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/Filament/Admin/ViewEggTest.php b/tests/Filament/Admin/ViewEggTest.php index 11701a9020..08aaf2f6e7 100644 --- a/tests/Filament/Admin/ViewEggTest.php +++ b/tests/Filament/Admin/ViewEggTest.php @@ -12,6 +12,8 @@ use function Pest\Livewire\livewire; +afterEach(fn () => Filament::setCurrentPanel(null)); + /** @param string[] $abilities */ function eggRole(string $name, array $abilities): Role { diff --git a/tests/Filament/Admin/ViewNodeTest.php b/tests/Filament/Admin/ViewNodeTest.php index 4db994e7b3..fcbd7e7e4a 100644 --- a/tests/Filament/Admin/ViewNodeTest.php +++ b/tests/Filament/Admin/ViewNodeTest.php @@ -18,6 +18,8 @@ use function Pest\Livewire\livewire; +afterEach(fn () => Filament::setCurrentPanel(null)); + /** @param string[] $abilities */ function nodeRole(string $name, array $abilities): Role { diff --git a/tests/Filament/Admin/ViewServerTest.php b/tests/Filament/Admin/ViewServerTest.php index 44009c0e64..5edbf0866f 100644 --- a/tests/Filament/Admin/ViewServerTest.php +++ b/tests/Filament/Admin/ViewServerTest.php @@ -23,6 +23,8 @@ use function Pest\Livewire\livewire; +afterEach(fn () => Filament::setCurrentPanel(null)); + /** @param string[] $abilities */ function serverRole(string $name, array $abilities): Role { From 8a19578293cd8d82a27668a5abbf3af63211871f Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sun, 14 Jun 2026 08:27:09 +0100 Subject: [PATCH 19/20] swap deprecated table asserts --- tests/Filament/Admin/ViewEggTest.php | 4 ++-- tests/Filament/Admin/ViewNodeTest.php | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Filament/Admin/ViewEggTest.php b/tests/Filament/Admin/ViewEggTest.php index 08aaf2f6e7..55b2ec4324 100644 --- a/tests/Filament/Admin/ViewEggTest.php +++ b/tests/Filament/Admin/ViewEggTest.php @@ -113,9 +113,9 @@ function eggRole(string $name, array $abilities): Role $this->actingAs($viewer); livewire(ListEggs::class) - ->assertTableActionVisible('view', $egg); + ->assertActionVisible(TestAction::make('view')->table($egg)); $this->actingAs($editor); livewire(ListEggs::class) - ->assertTableActionHidden('view', $egg); + ->assertActionHidden(TestAction::make('view')->table($egg)); }); diff --git a/tests/Filament/Admin/ViewNodeTest.php b/tests/Filament/Admin/ViewNodeTest.php index fcbd7e7e4a..d4511c5b5d 100644 --- a/tests/Filament/Admin/ViewNodeTest.php +++ b/tests/Filament/Admin/ViewNodeTest.php @@ -61,9 +61,9 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se 'ownerRecord' => $node, 'pageClass' => ViewNode::class, ]) - ->assertTableActionHidden('create new allocation') - ->assertTableActionHidden(UpdateNodeAllocations::class) - ->assertTableBulkActionHidden(DeleteBulkAction::class) + ->assertActionHidden(TestAction::make('create new allocation')->table()) + ->assertActionHidden(TestAction::make(UpdateNodeAllocations::class)->table()) + ->assertActionHidden(TestAction::make(DeleteBulkAction::class)->table()->bulk()) ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias') ->call('updateTableColumnState', 'notes', (string) $allocation->getKey(), 'hacked-notes'); @@ -181,11 +181,11 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se $this->actingAs($viewer); livewire(ListNodes::class) - ->assertTableActionVisible('view', $node); + ->assertActionVisible(TestAction::make('view')->table($node)); $this->actingAs($editor); livewire(ListNodes::class) - ->assertTableActionHidden('view', $node); + ->assertActionHidden(TestAction::make('view')->table($node)); }); it('keeps the node relation managers read-only on the view page for a view-only user', function () { From d0825a921761d83f0eeb3a4dce64c993129bff40 Mon Sep 17 00:00:00 2001 From: Noah Stefano Date: Sun, 14 Jun 2026 11:03:18 +0100 Subject: [PATCH 20/20] trim view-page tests --- tests/Filament/Admin/ViewEggTest.php | 2 +- tests/Filament/Admin/ViewNodeTest.php | 87 +++++------------- tests/Filament/Admin/ViewServerTest.php | 112 +++--------------------- 3 files changed, 33 insertions(+), 168 deletions(-) diff --git a/tests/Filament/Admin/ViewEggTest.php b/tests/Filament/Admin/ViewEggTest.php index 55b2ec4324..101803a237 100644 --- a/tests/Filament/Admin/ViewEggTest.php +++ b/tests/Filament/Admin/ViewEggTest.php @@ -108,7 +108,7 @@ function eggRole(string $name, array $abilities): Role RolePermissionModels::Egg->update(), ])); - // table action urls resolve against the current panel; the default is 'app', not 'admin' + // table action urls resolve against the current panel, which defaults to 'app' Filament::setCurrentPanel(Filament::getPanel('admin')); $this->actingAs($viewer); diff --git a/tests/Filament/Admin/ViewNodeTest.php b/tests/Filament/Admin/ViewNodeTest.php index d4511c5b5d..7fa5e76259 100644 --- a/tests/Filament/Admin/ViewNodeTest.php +++ b/tests/Filament/Admin/ViewNodeTest.php @@ -5,13 +5,10 @@ use App\Filament\Admin\Resources\Nodes\Pages\ListNodes; use App\Filament\Admin\Resources\Nodes\Pages\ViewNode; use App\Filament\Admin\Resources\Nodes\RelationManagers\AllocationsRelationManager; -use App\Filament\Admin\Resources\Nodes\RelationManagers\ServersRelationManager; -use App\Filament\Components\Actions\UpdateNodeAllocations; use App\Models\Allocation; use App\Models\Node; use App\Models\Role; use App\Models\Server; -use Filament\Actions\DeleteBulkAction; use Filament\Actions\Testing\TestAction; use Filament\Facades\Filament; use Spatie\Permission\Models\Permission; @@ -32,52 +29,6 @@ function nodeRole(string $name, array $abilities): Role return $role; } -/** @return array{Node, Allocation, Server} */ -function nodeWithMultiAllocationServer(): array -{ - $node = Node::factory()->create(); - $server = Server::factory()->withNode($node)->create(); - $allocations = Allocation::factory()->count(2)->create([ - 'node_id' => $node->getKey(), - 'server_id' => $server->getKey(), - ]); - $server->update(['allocation_id' => $allocations->last()->getKey()]); - - return [$node, $allocations->first(), $server->refresh()]; -} - -function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $server): void -{ - Filament::setCurrentPanel(Filament::getPanel('admin')); - - $alias = $allocation->ip_alias; - $notes = $allocation->notes; - $primary = $server->allocation_id; - // the primary column only offers the first allocation (take(1)); target that valid option while a - // different allocation is currently primary, so only the isReadOnly gate can stop the switch - $switchTarget = (string) $server->allocations->take(1)->first()->getKey(); - - livewire(AllocationsRelationManager::class, [ - 'ownerRecord' => $node, - 'pageClass' => ViewNode::class, - ]) - ->assertActionHidden(TestAction::make('create new allocation')->table()) - ->assertActionHidden(TestAction::make(UpdateNodeAllocations::class)->table()) - ->assertActionHidden(TestAction::make(DeleteBulkAction::class)->table()->bulk()) - ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias') - ->call('updateTableColumnState', 'notes', (string) $allocation->getKey(), 'hacked-notes'); - - livewire(ServersRelationManager::class, [ - 'ownerRecord' => $node, - 'pageClass' => ViewNode::class, - ]) - ->call('updateTableColumnState', 'allocation.id', (string) $server->getKey(), $switchTarget); - - expect($allocation->refresh()->ip_alias)->toBe($alias) - ->and($allocation->notes)->toBe($notes) - ->and($server->refresh()->allocation_id)->toBe($primary); -} - it('lets a user with view permission open the view page', function () { $node = Node::factory()->create(); @@ -153,7 +104,6 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se RolePermissionModels::Node->view(), ])); - // the config_file tab renders the wings config yaml, which embeds daemon_token; it must be gone on view $this->actingAs($viewer); livewire(ViewNode::class, ['record' => $node->getKey()]) ->assertDontSee($node->daemon_token) @@ -176,7 +126,6 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se RolePermissionModels::Node->update(), ])); - // table action urls resolve against the current panel; the default is 'app', not 'admin' Filament::setCurrentPanel(Filament::getPanel('admin')); $this->actingAs($viewer); @@ -188,22 +137,15 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se ->assertActionHidden(TestAction::make('view')->table($node)); }); -it('keeps the node relation managers read-only on the view page for a view-only user', function () { - [$node, $allocation, $server] = nodeWithMultiAllocationServer(); - - [$viewer] = generateTestAccount([]); - $viewer->syncRoles(nodeRole('Node Viewer', [ - RolePermissionModels::Node->viewAny(), - RolePermissionModels::Node->view(), - ])); - - $this->actingAs($viewer); - assertNodeViewIsReadOnly($node, $allocation, $server); -}); - -it('keeps the node relation managers read-only on the view page even for an update-capable user', function () { - [$node, $allocation, $server] = nodeWithMultiAllocationServer(); +it('keeps the node relation managers read-only on the view page', function () { + $node = Node::factory()->create(); + $server = Server::factory()->withNode($node)->create(); + $allocation = Allocation::factory()->create([ + 'node_id' => $node->getKey(), + 'server_id' => $server->getKey(), + ]); + // user has update permission, so the block can only come from the view operation [$editor] = generateTestAccount([]); $editor->syncRoles(nodeRole('Node Editor', [ RolePermissionModels::Node->viewAny(), @@ -211,6 +153,17 @@ function assertNodeViewIsReadOnly(Node $node, Allocation $allocation, Server $se RolePermissionModels::Node->update(), ])); + Filament::setCurrentPanel(Filament::getPanel('admin')); $this->actingAs($editor); - assertNodeViewIsReadOnly($node, $allocation, $server); + + $alias = $allocation->ip_alias; + + livewire(AllocationsRelationManager::class, [ + 'ownerRecord' => $node, + 'pageClass' => ViewNode::class, + ]) + ->assertActionHidden(TestAction::make('create new allocation')->table()) + ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias'); + + expect($allocation->refresh()->ip_alias)->toBe($alias); }); diff --git a/tests/Filament/Admin/ViewServerTest.php b/tests/Filament/Admin/ViewServerTest.php index 5edbf0866f..3f745f7a27 100644 --- a/tests/Filament/Admin/ViewServerTest.php +++ b/tests/Filament/Admin/ViewServerTest.php @@ -5,20 +5,13 @@ use App\Filament\Admin\Resources\Servers\Pages\ListServers; use App\Filament\Admin\Resources\Servers\Pages\ViewServer; use App\Filament\Admin\Resources\Servers\RelationManagers\AllocationsRelationManager; -use App\Filament\Admin\Resources\Servers\RelationManagers\DatabasesRelationManager; use App\Models\Allocation; use App\Models\Role; use App\Models\Server; use App\Models\ServerVariable; -use Filament\Actions\AssociateAction; use Filament\Actions\CreateAction; -use Filament\Actions\DeleteAction; -use Filament\Actions\DissociateAction; -use Filament\Actions\DissociateBulkAction; use Filament\Actions\Testing\TestAction; use Filament\Facades\Filament; -use Filament\Forms\Components\TextInput; -use Filament\Schemas\Schema; use Spatie\Permission\Models\Permission; use function Pest\Livewire\livewire; @@ -45,65 +38,6 @@ function serverAllocation(Server $server): Allocation ]); } -function assertServerViewIsReadOnly(Server $server, Allocation $allocation): void -{ - Filament::setCurrentPanel(Filament::getPanel('admin')); - - $primaryBefore = $server->allocation_id; - $aliasBefore = $allocation->ip_alias; - $notesBefore = $allocation->notes; - - livewire(AllocationsRelationManager::class, [ - 'ownerRecord' => $server, - 'pageClass' => ViewServer::class, - ]) - ->assertActionHidden(TestAction::make('make-primary')->table($allocation)) - ->assertActionHidden(TestAction::make('lock')->table($allocation)) - ->assertActionHidden(TestAction::make(DissociateAction::class)->table($allocation)) - ->assertActionHidden(TestAction::make(CreateAction::class)->table()) - ->assertActionHidden(TestAction::make(AssociateAction::class)->table()) - ->assertActionHidden(TestAction::make(DissociateBulkAction::class)->table()->bulk()) - // primary is an IconColumn action, not hideable; callTableColumnAction ignores disabled(), so the - // in-closure isReadOnly guard is what must stop the write, and the unchanged allocation_id proves it - ->callTableColumnAction('primary', $allocation->getKey()) - ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias') - ->call('updateTableColumnState', 'notes', (string) $allocation->getKey(), 'hacked-notes'); - - livewire(DatabasesRelationManager::class, [ - 'ownerRecord' => $server, - 'pageClass' => ViewServer::class, - ]) - ->assertActionHidden(TestAction::make(CreateAction::class)->table()) - ->assertActionHidden(TestAction::make(DeleteAction::class)->table()); - - expect($server->refresh()->allocation_id)->toBe($primaryBefore) - ->and($allocation->refresh()->ip_alias)->toBe($aliasBefore) - ->and($allocation->notes)->toBe($notesBefore); -} - -// the rotate hintAction sits inside the database view modal, which standalone relation managers can't mount in -// tests, and isHidden() folds in the action's record-scoped authorize(); so resolve the action off the built -// form and evaluate just its view-page gate (the ->hidden closure wired to the manager isReadOnly state) -function databaseRotateHidden(Server $server, string $pageClass): bool -{ - Filament::setCurrentPanel(Filament::getPanel('admin')); - - $manager = livewire(DatabasesRelationManager::class, [ - 'ownerRecord' => $server, - 'pageClass' => $pageClass, - ])->instance(); - - $password = collect($manager->form(Schema::make($manager))->getComponents()) - ->first(fn ($component) => $component instanceof TextInput && $component->getName() === 'password'); - - $rotate = collect($password->getHintActions()) - ->first(fn ($action) => $action->getName() === 'exclude_hint_rotate'); - - $gate = (new ReflectionProperty($rotate, 'isHidden'))->getValue($rotate); - - return (bool) $rotate->evaluate($gate); -} - it('lets a user with view permission open the view page', function () { [$user, $server] = generateTestAccount([]); $user->syncRoles(serverRole('Server Viewer', [ @@ -193,7 +127,6 @@ function databaseRotateHidden(Server $server, string $pageClass): bool RolePermissionModels::Server->update(), ])); - // table action urls resolve against the current panel; the default is 'app', not 'admin' Filament::setCurrentPanel(Filament::getPanel('admin')); $this->actingAs($viewer); @@ -205,48 +138,27 @@ function databaseRotateHidden(Server $server, string $pageClass): bool ->assertActionHidden(TestAction::make('view')->table($server)); }); -it('keeps the server relation managers read-only on the view page for a view-only user', function () { - [$viewer, $server] = generateTestAccount([]); - $viewer->syncRoles(serverRole('Server Viewer', [ - RolePermissionModels::Server->viewAny(), - RolePermissionModels::Server->view(), - ])); - - $this->actingAs($viewer); - assertServerViewIsReadOnly($server, serverAllocation($server)); -}); - -it('keeps the server relation managers read-only on the view page even for an update-capable user', function () { +it('keeps the server relation managers read-only on the view page', function () { [$editor, $server] = generateTestAccount([]); $editor->syncRoles(serverRole('Server Editor', [ RolePermissionModels::Server->viewAny(), RolePermissionModels::Server->view(), RolePermissionModels::Server->update(), ])); + $allocation = serverAllocation($server); + // editor can update, so the view operation is what gates this write + Filament::setCurrentPanel(Filament::getPanel('admin')); $this->actingAs($editor); - assertServerViewIsReadOnly($server, serverAllocation($server)); -}); - -it('hides database password rotation behind the read-only gate on the view page', function () { - [$viewer, $server] = generateTestAccount([]); - $viewer->syncRoles(serverRole('Server Viewer', [ - RolePermissionModels::Server->viewAny(), - RolePermissionModels::Server->view(), - ])); - $this->actingAs($viewer); - expect(databaseRotateHidden($server, ViewServer::class))->toBeTrue(); -}); + $alias = $allocation->ip_alias; -it('leaves database password rotation available on the edit page', function () { - [$editor, $server] = generateTestAccount([]); - $editor->syncRoles(serverRole('Server Editor', [ - RolePermissionModels::Server->viewAny(), - RolePermissionModels::Server->view(), - RolePermissionModels::Server->update(), - ])); + livewire(AllocationsRelationManager::class, [ + 'ownerRecord' => $server, + 'pageClass' => ViewServer::class, + ]) + ->assertActionHidden(TestAction::make(CreateAction::class)->table()) + ->call('updateTableColumnState', 'ip_alias', (string) $allocation->getKey(), 'hacked-alias'); - $this->actingAs($editor); - expect(databaseRotateHidden($server, EditServer::class))->toBeFalse(); + expect($allocation->refresh()->ip_alias)->toBe($alias); });