From dec1cf8e74ed8ad5bbdd7ee1d6d8fd3eb0aafa06 Mon Sep 17 00:00:00 2001 From: notCharles Date: Mon, 27 May 2024 20:02:13 -0400 Subject: [PATCH] Rework Edit Server Page a WIP, Also functional --- .../ServerResource/Pages/EditServer.php | 974 ++++++++++-------- .../ServerResource/Pages/EditServerOrg.php | 588 +++++++++++ .../Controllers/Admin/ServersController.php | 20 +- app/Services/Servers/SuspensionService.php | 6 +- 4 files changed, 1151 insertions(+), 437 deletions(-) create mode 100644 app/Filament/Resources/ServerResource/Pages/EditServerOrg.php diff --git a/app/Filament/Resources/ServerResource/Pages/EditServer.php b/app/Filament/Resources/ServerResource/Pages/EditServer.php index c047301a1..ef4ce86bf 100644 --- a/app/Filament/Resources/ServerResource/Pages/EditServer.php +++ b/app/Filament/Resources/ServerResource/Pages/EditServer.php @@ -3,7 +3,9 @@ namespace App\Filament\Resources\ServerResource\Pages; use App\Filament\Resources\ServerResource; +use App\Http\Controllers\Admin\ServersController; use App\Services\Servers\RandomWordService; +use App\Services\Servers\SuspensionService; use Filament\Actions; use Filament\Forms; use App\Enums\ContainerStatus; @@ -13,10 +15,12 @@ use App\Models\Server; use App\Models\ServerVariable; use App\Repositories\Daemon\DaemonServerRepository; use App\Services\Servers\ServerDeletionService; +use Filament\Forms\Components\Tabs; use Filament\Forms\Form; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Facades\Validator; use Closure; +use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction; class EditServer extends EditRecord { @@ -25,12 +29,6 @@ class EditServer extends EditRecord public function form(Form $form): Form { return $form - ->columns([ - 'default' => 2, - 'sm' => 2, - 'md' => 4, - 'lg' => 6, - ]) ->schema([ Forms\Components\ToggleButtons::make('docker') ->label('Container Status')->inline()->inlineLabel() @@ -82,440 +80,554 @@ class EditServer extends EditRecord 'lg' => 3, ]), - Forms\Components\TextInput::make('external_id') - ->maxLength(191) - ->hidden(), - - Forms\Components\TextInput::make('name') - ->prefixIcon('tabler-server') - ->label('Display Name') - ->suffixAction(Forms\Components\Actions\Action::make('random') - ->icon('tabler-dice-' . random_int(1, 6)) - ->action(function (Forms\Set $set, Forms\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' => 4, - 'md' => 2, - 'lg' => 3, - ]) - ->required() - ->maxLength(191), - - Forms\Components\Select::make('owner_id') - ->prefixIcon('tabler-user') - ->label('Owner') - ->columnSpan([ - 'default' => 2, - 'sm' => 4, - 'md' => 2, - 'lg' => 3, - ]) - ->relationship('user', 'username') - ->searchable() - ->preload() - ->required(), - - Forms\Components\Textarea::make('description') - ->hidden() - ->required() - ->columnSpanFull(), - - Forms\Components\Select::make('egg_id') - ->disabledOn('edit') - ->prefixIcon('tabler-egg') - ->columnSpan([ - 'default' => 2, - 'sm' => 2, - 'md' => 2, - 'lg' => 5, - ]) - ->relationship('egg', 'name') - ->searchable() - ->preload() - ->required(), - - Forms\Components\ToggleButtons::make('skip_scripts') - ->label('Run Egg Install Script?')->inline() - ->options([ - false => 'Yes', - true => 'Skip', - ]) - ->colors([ - false => 'primary', - true => 'danger', - ]) - ->icons([ - false => 'tabler-code', - true => 'tabler-code-off', - ]) - ->required(), - - Forms\Components\Textarea::make('startup') - ->hintIcon('tabler-code') - ->label('Startup Command') - ->required() - ->live() - ->columnSpan([ - 'default' => 2, - 'sm' => 4, - 'md' => 4, - 'lg' => 6, - ]) - ->rows(function ($state) { - return str($state)->explode("\n")->reduce( - fn (int $carry, $line) => $carry + floor(strlen($line) / 125), - 0 - ); - }), - - Forms\Components\Hidden::make('start_on_completion'), - - Forms\Components\Section::make('Egg Variables') - ->icon('tabler-eggs') - ->iconColor('primary') - ->collapsible() - ->collapsed() - ->columnSpan(([ - 'default' => 2, - 'sm' => 4, - 'md' => 4, - 'lg' => 6, - ])) - ->schema([ - Forms\Components\Repeater::make('server_variables') - ->relationship('serverVariables') - ->grid() - ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { - foreach ($data as $key => $value) { - if (!isset($data['variable_value'])) { - $data['variable_value'] = ''; - } - } - - return $data; - }) - ->reorderable(false)->addable(false)->deletable(false) - ->schema(function () { - - $text = Forms\Components\TextInput::make('variable_value') - ->hidden($this->shouldHideComponent(...)) - ->maxLength(191) - ->rules([ - fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) { - $validator = Validator::make(['validatorkey' => $value], [ - 'validatorkey' => $serverVariable->variable->rules, - ]); - - if ($validator->fails()) { - $message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name); - - $fail($message); - } - }, - ]); - - $select = Forms\Components\Select::make('variable_value') - ->hidden($this->shouldHideComponent(...)) - ->options($this->getSelectOptionsFromRules(...)) - ->selectablePlaceholder(false); - - $components = [$text, $select]; - - /** @var Forms\Components\Component $component */ - foreach ($components as &$component) { - $component = $component - ->live(onBlur: true) - ->hintIcon('tabler-code') - ->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name) - ->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules) - ->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}') - ->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description); - } - - return $components; - }) - ->columnSpan(2), - ]), - - Forms\Components\Section::make('Environment Management') - ->collapsed() - ->icon('tabler-server-cog') - ->iconColor('primary') + Tabs::make('Tabs') + ->persistTabInQueryString() + ->columnSpan(6) ->columns([ 'default' => 2, - 'sm' => 4, + 'sm' => 2, 'md' => 4, - 'lg' => 4, + 'lg' => 6, ]) - ->columnSpanFull() - ->schema([ - Forms\Components\Fieldset::make('Resource Limits') - ->columnSpan([ - 'default' => 2, - 'sm' => 4, - 'md' => 4, - 'lg' => 6, - ]) - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) + ->tabs([ + Tabs\Tab::make('Information') ->schema([ - Forms\Components\Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Forms\Components\ToggleButtons::make('unlimited_mem') - ->label('Memory')->inlineLabel()->inline() - ->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0)) - ->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0) - ->live() - ->options([ - true => 'Unlimited', - false => 'Limited', - ]) - ->colors([ - true => 'primary', - false => 'warning', - ]) - ->columnSpan(2), + Forms\Components\TextInput::make('name') + ->prefixIcon('tabler-server') + ->label('Display Name') + ->suffixAction(Forms\Components\Actions\Action::make('random') + ->icon('tabler-dice-' . random_int(1, 6)) + ->action(function (Forms\Set $set, Forms\Get $get) { + $egg = Egg::find($get('egg_id')); + $prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : ''; - Forms\Components\TextInput::make('memory') - ->dehydratedWhenHidden() - ->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) - ->label('Memory Limit')->inlineLabel() - ->suffix('MiB') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), + $word = (new RandomWordService())->word(); - Forms\Components\Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Forms\Components\ToggleButtons::make('unlimited_disk') - ->label('Disk Space')->inlineLabel()->inline() - ->live() - ->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0)) - ->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0) - ->options([ - true => 'Unlimited', - false => 'Limited', - ]) - ->colors([ - true => 'primary', - false => 'warning', - ]) - ->columnSpan(2), - - Forms\Components\TextInput::make('disk') - ->dehydratedWhenHidden() - ->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) - ->label('Disk Space Limit')->inlineLabel() - ->suffix('MiB') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - - Forms\Components\Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Forms\Components\ToggleButtons::make('unlimited_cpu') - ->label('CPU')->inlineLabel()->inline() - ->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0)) - ->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0) - ->live() - ->options([ - true => 'Unlimited', - false => 'Limited', - ]) - ->colors([ - true => 'primary', - false => 'warning', - ]) - ->columnSpan(2), - - Forms\Components\TextInput::make('cpu') - ->dehydratedWhenHidden() - ->hidden(fn (Forms\Get $get) => $get('unlimited_cpu')) - ->label('CPU Limit')->inlineLabel() - ->suffix('%') - ->required() - ->columnSpan(2) - ->numeric() - ->minValue(0), - ]), - - Forms\Components\Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Forms\Components\ToggleButtons::make('swap_support') - ->live() - ->label('Enable Swap Memory')->inlineLabel()->inline() - ->columnSpan(2) - ->afterStateUpdated(function ($state, Forms\Set $set) { - $value = match ($state) { - 'unlimited' => -1, - 'disabled' => 0, - 'limited' => 128, - }; - - $set('swap', $value); - }) - ->formatStateUsing(function (Forms\Get $get) { - return match (true) { - $get('swap') > 0 => 'limited', - $get('swap') == 0 => 'disabled', - $get('swap') < 0 => 'unlimited', - }; - }) - ->options([ - 'unlimited' => 'Unlimited', - 'limited' => 'Limited', - 'disabled' => 'Disabled', - ]) - ->colors([ - 'unlimited' => 'primary', - 'limited' => 'warning', - 'disabled' => 'danger', - ]), - - Forms\Components\TextInput::make('swap') - ->dehydratedWhenHidden() - ->hidden(fn (Forms\Get $get) => match ($get('swap_support')) { - 'disabled', 'unlimited', true => true, - 'limited', false => false, - }) - ->label('Swap Memory')->inlineLabel() - ->suffix('MiB') - ->minValue(-1) - ->columnSpan(2) - ->required() - ->integer(), - ]), - - Forms\Components\Hidden::make('io') - ->helperText('The IO performance relative to other running containers') - ->label('Block IO Proportion'), - - Forms\Components\Grid::make() - ->columns(4) - ->columnSpanFull() - ->schema([ - Forms\Components\ToggleButtons::make('oom_killer') - ->label('OOM Killer')->inlineLabel()->inline() - ->columnSpan(2) - ->options([ - false => 'Disabled', - true => 'Enabled', - ]) - ->colors([ - false => 'success', - true => 'danger', - ]), - - Forms\Components\TextInput::make('oom_disabled_hidden') - ->hidden(), - ]), - ]), - - Forms\Components\Fieldset::make('Feature Limits') - ->inlineLabel() - ->columnSpan([ - 'default' => 2, - 'sm' => 4, - 'md' => 4, - 'lg' => 6, - ]) - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Forms\Components\TextInput::make('allocation_limit') - ->suffixIcon('tabler-network') + $set('name', $prefix . $word); + })) + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) ->required() - ->numeric(), - Forms\Components\TextInput::make('database_limit') - ->suffixIcon('tabler-database') - ->required() - ->numeric(), - Forms\Components\TextInput::make('backup_limit') - ->suffixIcon('tabler-copy-check') - ->required() - ->numeric(), - ]), - Forms\Components\Fieldset::make('Docker Settings') - ->columnSpan([ - 'default' => 2, - 'sm' => 4, - 'md' => 4, - 'lg' => 6, - ]) - ->columns([ - 'default' => 1, - 'sm' => 2, - 'md' => 3, - 'lg' => 3, - ]) - ->schema([ - Forms\Components\Select::make('select_image') - ->label('Image Name') - ->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state)) - ->options(function ($state, Forms\Get $get, Forms\Set $set) { - $egg = Egg::query()->find($get('egg_id')); - $images = $egg->docker_images ?? []; + ->maxLength(191), - $currentImage = $get('image'); - if (!$currentImage && $images) { - $defaultImage = collect($images)->first(); - $set('image', $defaultImage); - $set('select_image', $defaultImage); - } + Forms\Components\Select::make('owner_id') + ->prefixIcon('tabler-user') + ->label('Owner') + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) + ->relationship('user', 'username') + ->searchable() + ->preload() + ->required(), - return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image']; - }) - ->selectablePlaceholder(false) - ->columnSpan(1), - - Forms\Components\TextInput::make('image') - ->label('Image') - ->debounce(500) - ->afterStateUpdated(function ($state, Forms\Get $get, Forms\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('Enter a custom Image') - ->columnSpan(1), - - Forms\Components\KeyValue::make('docker_labels') - ->label('Container Labels') - ->keyLabel('Label Name') - ->valueLabel('Label Description') + Forms\Components\Textarea::make('description') + ->label('Description') ->columnSpanFull(), + + Forms\Components\TextInput::make('uuid') + ->hintAction(CopyAction::make()) + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly(), + Forms\Components\TextInput::make('uuid_short') + ->label('Short UUID') + ->hintAction(CopyAction::make()) + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) + ->readOnly(), + Forms\Components\TextInput::make('external_id') + ->label('External ID') + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) + ->maxLength(191), + Forms\Components\Select::make('node_id') + ->label('Node') + ->relationship('node', 'name') + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]) + ->disabled(), + ]), + Tabs\Tab::make('Environment') + ->schema([ + Forms\Components\Fieldset::make('Resource Limits') + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_mem') + ->label('Memory')->inlineLabel()->inline() + ->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0) + ->live() + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) + ->label('Memory Limit')->inlineLabel() + ->suffix('MiB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_disk') + ->label('Disk Space')->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0) + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) + ->label('Disk Space Limit')->inlineLabel() + ->suffix('MiB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_cpu') + ->label('CPU')->inlineLabel()->inline() + ->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0) + ->live() + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_cpu')) + ->label('CPU Limit')->inlineLabel() + ->suffix('%') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('swap_support') + ->live() + ->label('Enable Swap Memory')->inlineLabel()->inline() + ->columnSpan(2) + ->afterStateUpdated(function ($state, Forms\Set $set) { + $value = match ($state) { + 'unlimited' => -1, + 'disabled' => 0, + 'limited' => 128, + }; + + $set('swap', $value); + }) + ->formatStateUsing(function (Forms\Get $get) { + return match (true) { + $get('swap') > 0 => 'limited', + $get('swap') == 0 => 'disabled', + $get('swap') < 0 => 'unlimited', + }; + }) + ->options([ + 'unlimited' => 'Unlimited', + 'limited' => 'Limited', + 'disabled' => 'Disabled', + ]) + ->colors([ + 'unlimited' => 'primary', + 'limited' => 'warning', + 'disabled' => 'danger', + ]), + + Forms\Components\TextInput::make('swap') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => match ($get('swap_support')) { + 'disabled', 'unlimited', true => true, + 'limited', false => false, + }) + ->label('Swap Memory')->inlineLabel() + ->suffix('MiB') + ->minValue(-1) + ->columnSpan(2) + ->required() + ->integer(), + ]), + + Forms\Components\Hidden::make('io') + ->helperText('The IO performance relative to other running containers') + ->label('Block IO Proportion'), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('oom_killer') + ->label('OOM Killer')->inlineLabel()->inline() + ->columnSpan(2) + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'danger', + ]), + + Forms\Components\TextInput::make('oom_disabled_hidden') + ->hidden(), + ]), + ]), + + Forms\Components\Fieldset::make('Feature Limits') + ->inlineLabel() + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\TextInput::make('allocation_limit') + ->suffixIcon('tabler-network') + ->required() + ->numeric(), + Forms\Components\TextInput::make('database_limit') + ->suffixIcon('tabler-database') + ->required() + ->numeric(), + Forms\Components\TextInput::make('backup_limit') + ->suffixIcon('tabler-copy-check') + ->required() + ->numeric(), + ]), + Forms\Components\Fieldset::make('Docker Settings') + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\Select::make('select_image') + ->label('Image Name') + ->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state)) + ->options(function ($state, Forms\Get $get, Forms\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(1), + + Forms\Components\TextInput::make('image') + ->label('Image') + ->debounce(500) + ->afterStateUpdated(function ($state, Forms\Get $get, Forms\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('Enter a custom Image') + ->columnSpan(2), + + Forms\Components\KeyValue::make('docker_labels') + ->label('Container Labels') + ->keyLabel('Label Name') + ->valueLabel('Label Description') + ->columnSpanFull(), + ]), + ]), + Tabs\Tab::make('Egg') + ->columns([ + 'default' => 1, + 'sm' => 3, + 'md' => 3, + 'lg' => 5, + ]) + ->schema([ + Forms\Components\Select::make('egg_id') + ->disabledOn('edit') + ->prefixIcon('tabler-egg') + ->columnSpan([ + 'default' => 1, + 'sm' => 3, + 'md' => 3, + 'lg' => 5, + ]) + ->relationship('egg', 'name') + ->searchable() + ->preload() + ->required(), + + Forms\Components\ToggleButtons::make('skip_scripts') + ->label('Run Egg Install Script?')->inline() + ->options([ + false => 'Yes', + true => 'Skip', + ]) + ->colors([ + false => 'primary', + true => 'danger', + ]) + ->icons([ + false => 'tabler-code', + true => 'tabler-code-off', + ]) + ->required(), + + Forms\Components\Textarea::make('startup') + ->label('Startup Command') + ->required() + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->rows(function ($state) { + return str($state)->explode("\n")->reduce( + fn (int $carry, $line) => $carry + floor(strlen($line) / 125), + 0 + ); + }), + + Forms\Components\Textarea::make('defaultStartup') + ->hintAction(CopyAction::make()) + ->label('Default Startup Command') + ->disabled() + ->formatStateUsing(function ($state, Forms\Get $get, Forms\Set $set) { + $egg = Egg::query()->find($get('egg_id')); + + return $egg->startup; + }) + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]), + + Forms\Components\Repeater::make('server_variables') + ->relationship('serverVariables') + ->grid() + ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { + foreach ($data as $key => $value) { + if (!isset($data['variable_value'])) { + $data['variable_value'] = ''; + } + } + + return $data; + }) + ->reorderable(false)->addable(false)->deletable(false) + ->schema(function () { + + $text = Forms\Components\TextInput::make('variable_value') + ->hidden($this->shouldHideComponent(...)) + ->maxLength(191) + ->rules([ + fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) { + $validator = Validator::make(['validatorkey' => $value], [ + 'validatorkey' => $serverVariable->variable->rules, + ]); + + if ($validator->fails()) { + $message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name); + + $fail($message); + } + }, + ]); + + $select = Forms\Components\Select::make('variable_value') + ->hidden($this->shouldHideComponent(...)) + ->options($this->getSelectOptionsFromRules(...)) + ->selectablePlaceholder(false); + + $components = [$text, $select]; + + /** @var Forms\Components\Component $component */ + foreach ($components as &$component) { + $component = $component + ->live(onBlur: true) + ->hintIcon('tabler-code') + ->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name) + ->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules) + ->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}') + ->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description); + } + + return $components; + }) + ->columnSpan(6), + ]), + Tabs\Tab::make('Mounts'), + Tabs\Tab::make('Databases'), + Tabs\Tab::make('Actions') + ->schema([ + Forms\Components\Fieldset::make('Server Actions') + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 2, + 'lg' => 6, + ]) + ->schema([ + Forms\Components\Grid::make() + ->columnSpan(3) + ->schema([ + Forms\Components\Actions::make([ + Forms\Components\Actions\Action::make('toggleInstall') + ->label('Toggle Status') + ->action(fn (ServersController $serversController, Server $server) => $serversController->toggleInstall($server)), + ])->fullWidth(), + Forms\Components\ToggleButtons::make('') + ->hint('If you need to change the install status from uninstalled to installed, or vice versa, you may do so with this button.'), + ]), + Forms\Components\Grid::make() + ->columnSpan(3) + ->schema([ + Forms\Components\Actions::make([ + Forms\Components\Actions\Action::make('toggleSuspend') + ->label('Suspend') + ->color('warning') + ->hidden(fn (Server $server) => $server->isSuspended()) + ->action(fn (SuspensionService $suspensionService, Server $server) => $suspensionService->toggle($server, 'suspend')), + Forms\Components\Actions\Action::make('toggleUnsuspend') + ->label('Unsuspend') + ->color('success') + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->action(fn (SuspensionService $suspensionService, Server $server) => $suspensionService->toggle($server, 'unsuspend')), + ])->fullWidth(), + Forms\Components\ToggleButtons::make('') + ->hidden(fn (Server $server) => $server->isSuspended()) + ->hint('This will suspend the server, stop any running processes, and immediately block the user from being able to access their files or otherwise manage the server through the panel or API.'), + Forms\Components\ToggleButtons::make('') + ->hidden(fn (Server $server) => !$server->isSuspended()) + ->hint('This will unsuspend the server and restore normal user access.'), + ]), + Forms\Components\Grid::make() + ->columnSpan(3) + ->schema([ + Forms\Components\Actions::make([ + Forms\Components\Actions\Action::make('transfer') + ->label('Transfer'), + ])->fullWidth(), + Forms\Components\ToggleButtons::make('') + ->hint('Transfer this server to another node connected to this panel. Warning! This feature has not been fully tested and may have bugs.'), + ]), + Forms\Components\Grid::make() + ->columnSpan(3) + ->schema([ + Forms\Components\Actions::make([ + Forms\Components\Actions\Action::make('reinstall') + ->label('Reinstall') + ->color('danger') + ->requiresConfirmation() + ->action(fn (ServersController $serversController, Server $server) => $serversController->reinstallServer($server)), + ])->fullWidth(), + Forms\Components\ToggleButtons::make('') + ->hint('This will reinstall the server with the assigned egg scripts. Danger! This could overwrite server data.'), + ]), + ]), ]), ]), ]); @@ -543,6 +655,10 @@ class EditServer extends EditRecord protected function mutateFormDataBeforeSave(array $data): array { + if (!isset($data['description'])) { + $data['description'] = ''; + } + unset($data['docker'], $data['status']); return $data; diff --git a/app/Filament/Resources/ServerResource/Pages/EditServerOrg.php b/app/Filament/Resources/ServerResource/Pages/EditServerOrg.php new file mode 100644 index 000000000..1ce585936 --- /dev/null +++ b/app/Filament/Resources/ServerResource/Pages/EditServerOrg.php @@ -0,0 +1,588 @@ +columns([ + 'default' => 2, + 'sm' => 2, + 'md' => 4, + 'lg' => 6, + ]) + ->schema([ + Forms\Components\ToggleButtons::make('docker') + ->label('Container Status')->inline()->inlineLabel() + ->formatStateUsing(function ($state, Server $server) { + if ($server->node_id === null) { + return 'unknown'; + } + + /** @var DaemonServerRepository $service */ + $service = resolve(DaemonServerRepository::class); + $details = $service->setServer($server)->getDetails(); + + return $details['state'] ?? 'unknown'; + }) + ->options(fn ($state) => collect(ContainerStatus::cases())->filter(fn ($containerStatus) => $containerStatus->value === $state)->mapWithKeys( + fn (ContainerStatus $state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()] + )) + ->colors(collect(ContainerStatus::cases())->mapWithKeys( + fn (ContainerStatus $status) => [$status->value => $status->color()] + )) + ->icons(collect(ContainerStatus::cases())->mapWithKeys( + fn (ContainerStatus $status) => [$status->value => $status->icon()] + )) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]), + + Forms\Components\ToggleButtons::make('status') + ->label('Server State')->inline()->inlineLabel() + ->helperText('') + + ->formatStateUsing(fn ($state) => $state ?? ServerState::Normal) + ->options(fn ($state) => collect(ServerState::cases())->filter(fn ($serverState) => $serverState->value === $state)->mapWithKeys( + fn (ServerState $state) => [$state->value => str($state->value)->replace('_', ' ')->ucwords()] + )) + ->colors(collect(ServerState::cases())->mapWithKeys( + fn (ServerState $state) => [$state->value => $state->color()] + )) + ->icons(collect(ServerState::cases())->mapWithKeys( + fn (ServerState $state) => [$state->value => $state->icon()] + )) + ->columnSpan([ + 'default' => 1, + 'sm' => 2, + 'md' => 2, + 'lg' => 3, + ]), + + Forms\Components\TextInput::make('external_id') + ->maxLength(191) + ->hidden(), + + Forms\Components\TextInput::make('name') + ->prefixIcon('tabler-server') + ->label('Display Name') + ->suffixAction(Forms\Components\Actions\Action::make('random') + ->icon('tabler-dice-' . random_int(1, 6)) + ->action(function (Forms\Set $set, Forms\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' => 4, + 'md' => 2, + 'lg' => 3, + ]) + ->required() + ->maxLength(191), + + Forms\Components\Select::make('owner_id') + ->prefixIcon('tabler-user') + ->label('Owner') + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 2, + 'lg' => 3, + ]) + ->relationship('user', 'username') + ->searchable() + ->preload() + ->required(), + + Forms\Components\Textarea::make('description') + ->hidden() + ->required() + ->columnSpanFull(), + + Forms\Components\Select::make('egg_id') + ->disabledOn('edit') + ->prefixIcon('tabler-egg') + ->columnSpan([ + 'default' => 2, + 'sm' => 2, + 'md' => 2, + 'lg' => 5, + ]) + ->relationship('egg', 'name') + ->searchable() + ->preload() + ->required(), + + Forms\Components\ToggleButtons::make('skip_scripts') + ->label('Run Egg Install Script?')->inline() + ->options([ + false => 'Yes', + true => 'Skip', + ]) + ->colors([ + false => 'primary', + true => 'danger', + ]) + ->icons([ + false => 'tabler-code', + true => 'tabler-code-off', + ]) + ->required(), + + Forms\Components\Textarea::make('startup') + ->hintIcon('tabler-code') + ->label('Startup Command') + ->required() + ->live() + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->rows(function ($state) { + return str($state)->explode("\n")->reduce( + fn (int $carry, $line) => $carry + floor(strlen($line) / 125), + 0 + ); + }), + + Forms\Components\Hidden::make('start_on_completion'), + + Forms\Components\Section::make('Egg Variables') + ->icon('tabler-eggs') + ->iconColor('primary') + ->collapsible() + ->collapsed() + ->columnSpan(([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ])) + ->schema([ + Forms\Components\Repeater::make('server_variables') + ->relationship('serverVariables') + ->grid() + ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array { + foreach ($data as $key => $value) { + if (!isset($data['variable_value'])) { + $data['variable_value'] = ''; + } + } + + return $data; + }) + ->reorderable(false)->addable(false)->deletable(false) + ->schema(function () { + + $text = Forms\Components\TextInput::make('variable_value') + ->hidden($this->shouldHideComponent(...)) + ->maxLength(191) + ->rules([ + fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) { + $validator = Validator::make(['validatorkey' => $value], [ + 'validatorkey' => $serverVariable->variable->rules, + ]); + + if ($validator->fails()) { + $message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name); + + $fail($message); + } + }, + ]); + + $select = Forms\Components\Select::make('variable_value') + ->hidden($this->shouldHideComponent(...)) + ->options($this->getSelectOptionsFromRules(...)) + ->selectablePlaceholder(false); + + $components = [$text, $select]; + + /** @var Forms\Components\Component $component */ + foreach ($components as &$component) { + $component = $component + ->live(onBlur: true) + ->hintIcon('tabler-code') + ->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name) + ->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules) + ->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}') + ->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description); + } + + return $components; + }) + ->columnSpan(2), + ]), + + Forms\Components\Section::make('Environment Management') + ->collapsed() + ->icon('tabler-server-cog') + ->iconColor('primary') + ->columns([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 4, + ]) + ->columnSpanFull() + ->schema([ + Forms\Components\Fieldset::make('Resource Limits') + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_mem') + ->label('Memory')->inlineLabel()->inline() + ->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('memory') == 0) + ->live() + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('memory') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) + ->label('Memory Limit')->inlineLabel() + ->suffix('MiB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_disk') + ->label('Disk Space')->inlineLabel()->inline() + ->live() + ->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('disk') == 0) + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('disk') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) + ->label('Disk Space Limit')->inlineLabel() + ->suffix('MiB') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('unlimited_cpu') + ->label('CPU')->inlineLabel()->inline() + ->afterStateUpdated(fn (Forms\Set $set) => $set('cpu', 0)) + ->formatStateUsing(fn (Forms\Get $get) => $get('cpu') == 0) + ->live() + ->options([ + true => 'Unlimited', + false => 'Limited', + ]) + ->colors([ + true => 'primary', + false => 'warning', + ]) + ->columnSpan(2), + + Forms\Components\TextInput::make('cpu') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => $get('unlimited_cpu')) + ->label('CPU Limit')->inlineLabel() + ->suffix('%') + ->required() + ->columnSpan(2) + ->numeric() + ->minValue(0), + ]), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('swap_support') + ->live() + ->label('Enable Swap Memory')->inlineLabel()->inline() + ->columnSpan(2) + ->afterStateUpdated(function ($state, Forms\Set $set) { + $value = match ($state) { + 'unlimited' => -1, + 'disabled' => 0, + 'limited' => 128, + }; + + $set('swap', $value); + }) + ->formatStateUsing(function (Forms\Get $get) { + return match (true) { + $get('swap') > 0 => 'limited', + $get('swap') == 0 => 'disabled', + $get('swap') < 0 => 'unlimited', + }; + }) + ->options([ + 'unlimited' => 'Unlimited', + 'limited' => 'Limited', + 'disabled' => 'Disabled', + ]) + ->colors([ + 'unlimited' => 'primary', + 'limited' => 'warning', + 'disabled' => 'danger', + ]), + + Forms\Components\TextInput::make('swap') + ->dehydratedWhenHidden() + ->hidden(fn (Forms\Get $get) => match ($get('swap_support')) { + 'disabled', 'unlimited', true => true, + 'limited', false => false, + }) + ->label('Swap Memory')->inlineLabel() + ->suffix('MiB') + ->minValue(-1) + ->columnSpan(2) + ->required() + ->integer(), + ]), + + Forms\Components\Hidden::make('io') + ->helperText('The IO performance relative to other running containers') + ->label('Block IO Proportion'), + + Forms\Components\Grid::make() + ->columns(4) + ->columnSpanFull() + ->schema([ + Forms\Components\ToggleButtons::make('oom_killer') + ->label('OOM Killer')->inlineLabel()->inline() + ->columnSpan(2) + ->options([ + false => 'Disabled', + true => 'Enabled', + ]) + ->colors([ + false => 'success', + true => 'danger', + ]), + + Forms\Components\TextInput::make('oom_disabled_hidden') + ->hidden(), + ]), + ]), + + Forms\Components\Fieldset::make('Feature Limits') + ->inlineLabel() + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\TextInput::make('allocation_limit') + ->suffixIcon('tabler-network') + ->required() + ->numeric(), + Forms\Components\TextInput::make('database_limit') + ->suffixIcon('tabler-database') + ->required() + ->numeric(), + Forms\Components\TextInput::make('backup_limit') + ->suffixIcon('tabler-copy-check') + ->required() + ->numeric(), + ]), + Forms\Components\Fieldset::make('Docker Settings') + ->columnSpan([ + 'default' => 2, + 'sm' => 4, + 'md' => 4, + 'lg' => 6, + ]) + ->columns([ + 'default' => 1, + 'sm' => 2, + 'md' => 3, + 'lg' => 3, + ]) + ->schema([ + Forms\Components\Select::make('select_image') + ->label('Image Name') + ->afterStateUpdated(fn (Forms\Set $set, $state) => $set('image', $state)) + ->options(function ($state, Forms\Get $get, Forms\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(1), + + Forms\Components\TextInput::make('image') + ->label('Image') + ->debounce(500) + ->afterStateUpdated(function ($state, Forms\Get $get, Forms\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('Enter a custom Image') + ->columnSpan(1), + + Forms\Components\KeyValue::make('docker_labels') + ->label('Container Labels') + ->keyLabel('Label Name') + ->valueLabel('Label Description') + ->columnSpanFull(), + ]), + ]), + ]); + } + protected function getHeaderActions(): array + { + return [ + Actions\DeleteAction::make('Delete') + ->successRedirectUrl(route('filament.admin.resources.servers.index')) + ->color('danger') + ->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server)) + ->requiresConfirmation(), + Actions\Action::make('console') + ->label('Console') + ->icon('tabler-terminal') + ->url(fn (Server $server) => "/server/$server->uuid_short"), + $this->getSaveFormAction()->formId('form'), + ]; + + } + protected function getFormActions(): array + { + return []; + } + + protected function mutateFormDataBeforeSave(array $data): array + { + unset($data['docker'], $data['status']); + + return $data; + } + + public function getRelationManagers(): array + { + return [ + ServerResource\RelationManagers\AllocationsRelationManager::class, + ]; + } + + private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool + { + $containsRuleIn = str($get('rules'))->explode('|')->reduce( + fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true + ); + + if ($component instanceof Forms\Components\Select) { + return $containsRuleIn; + } + + if ($component instanceof Forms\Components\TextInput) { + return !$containsRuleIn; + } + + throw new \Exception('Component type not supported: ' . $component::class); + } + + private function getSelectOptionsFromRules(Forms\Get $get): array + { + $inRule = str($get('rules'))->explode('|')->reduce( + fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, '' + ); + + return str($inRule) + ->after('in:') + ->explode(',') + ->each(fn ($value) => str($value)->trim()) + ->mapWithKeys(fn ($value) => [$value => $value]) + ->all(); + } +} diff --git a/app/Http/Controllers/Admin/ServersController.php b/app/Http/Controllers/Admin/ServersController.php index 5a2b8074f..d71a689cf 100644 --- a/app/Http/Controllers/Admin/ServersController.php +++ b/app/Http/Controllers/Admin/ServersController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Enums\ServerState; +use Filament\Notifications\Notification; use Illuminate\Http\Request; use App\Models\User; use Illuminate\Http\Response; @@ -70,7 +71,7 @@ class ServersController extends Controller * @throws \App\Exceptions\DisplayException * @throws \App\Exceptions\Model\DataValidationException */ - public function toggleInstall(Server $server): RedirectResponse + public function toggleInstall(Server $server) { if ($server->status === ServerState::InstallFailed) { throw new DisplayException(trans('admin/server.exceptions.marked_as_failed')); @@ -79,9 +80,13 @@ class ServersController extends Controller $server->status = $server->isInstalled() ? ServerState::Installing : null; $server->save(); - $this->alert->success(trans('admin/server.alerts.install_toggled'))->flash(); + Notification::make() + ->title('Success!') + ->body(trans('admin/server.alerts.install_toggled')) + ->success() + ->send(); - return redirect()->route('admin.servers.view.manage', $server->id); + return null; } /** @@ -90,12 +95,15 @@ class ServersController extends Controller * @throws \App\Exceptions\DisplayException * @throws \App\Exceptions\Model\DataValidationException */ - public function reinstallServer(Server $server): RedirectResponse + public function reinstallServer(Server $server) { $this->reinstallService->handle($server); - $this->alert->success(trans('admin/server.alerts.server_reinstalled'))->flash(); - return redirect()->route('admin.servers.view.manage', $server->id); + Notification::make() + ->title('Success!') + ->body(trans('admin/server.alerts.server_reinstalled')) + ->success() + ->send(); } /** diff --git a/app/Services/Servers/SuspensionService.php b/app/Services/Servers/SuspensionService.php index 5e317fc1b..aef02b7b2 100644 --- a/app/Services/Servers/SuspensionService.php +++ b/app/Services/Servers/SuspensionService.php @@ -3,6 +3,7 @@ namespace App\Services\Servers; use App\Enums\ServerState; +use Filament\Notifications\Notification; use Webmozart\Assert\Assert; use App\Models\Server; use App\Repositories\Daemon\DaemonServerRepository; @@ -26,7 +27,7 @@ class SuspensionService * * @throws \Throwable */ - public function toggle(Server $server, string $action = self::ACTION_SUSPEND): void + public function toggle(Server $server, string $action = self::ACTION_SUSPEND) { Assert::oneOf($action, [self::ACTION_SUSPEND, self::ACTION_UNSUSPEND]); @@ -35,11 +36,12 @@ class SuspensionService // suspended in the database. Additionally, nothing needs to happen if the server // is not suspended, and we try to un-suspend the instance. if ($isSuspending === $server->isSuspended()) { - return; + return Notification::make()->danger()->title('Failed!')->body('Server is already suspended!')->send(); } // Check if the server is currently being transferred. if (!is_null($server->transfer)) { + Notification::make()->danger()->title('Failed!')->body('Server is currently being transferred.')->send(); throw new ConflictHttpException('Cannot toggle suspension status on a server that is currently being transferred.'); }