diff --git a/app/Extensions/Features/FeatureProvider.php b/app/Extensions/Features/FeatureProvider.php new file mode 100644 index 000000000..eca3ffbb0 --- /dev/null +++ b/app/Extensions/Features/FeatureProvider.php @@ -0,0 +1,51 @@ + + */ + protected static array $providers = []; + + /** + * @param string[] $id + * @return self|static[] + */ + public static function getProviders(string|array|null $id = null): array|self + { + if (is_array($id)) { + return array_intersect_key(static::$providers, array_flip($id)); + } + + return $id ? static::$providers[$id] : static::$providers; + } + + protected function __construct(protected Application $app) + { + if (array_key_exists($this->getId(), static::$providers)) { + if (!$this->app->runningUnitTests()) { + logger()->warning("Tried to create duplicate Feature provider with id '{$this->getId()}'"); + } + + return; + } + + static::$providers[$this->getId()] = $this; + } + + abstract public function getId(): string; + + /** + * A matching subset string (case-sensitive) from the console output + * + * @return array + */ + abstract public function getListeners(): array; + + abstract public function getAction(): Action; +} diff --git a/app/Extensions/Features/GSLToken.php b/app/Extensions/Features/GSLToken.php new file mode 100644 index 000000000..c4fc8508c --- /dev/null +++ b/app/Extensions/Features/GSLToken.php @@ -0,0 +1,122 @@ + */ + public function getListeners(): array + { + return [ + 'gsl token expired', + 'account not found', + ]; + } + + public function getId(): string + { + return 'gsltoken'; + } + + public function getAction(): Action + { + /** @var Server $server */ + $server = Filament::getTenant(); + + /** @var ServerVariable $serverVariable */ + $serverVariable = $server->serverVariables()->where('env_variable', 'STEAM_ACC')->first(); + + return Action::make($this->getId()) + ->requiresConfirmation() + ->modalHeading('Invalid GSL token') + ->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.') + ->modalSubmitActionLabel('Update GSL Token') + ->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server)) + ->form([ + Placeholder::make('java') + ->label('You can either generate a new one and enter it below or leave the field blank to remove it + completely.'), + TextInput::make('gsltoken') + ->label('GSL Token') + ->rules([ + fn (): 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); + } + }, + ]) + ->hintIcon('tabler-code') + ->label(fn () => $serverVariable->variable->name) + ->hintIconTooltip(fn () => implode('|', $serverVariable->variable->rules)) + ->prefix(fn () => '{{' . $serverVariable->variable->env_variable . '}}') + ->helperText(fn () => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description), + ]) + ->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server, $serverVariable) { + /** @var Server $server */ + $server = Filament::getTenant(); + try { + $new = $data['gsltoken'] ?? ''; + $original = $serverVariable->variable_value; + + $serverVariable->update([ + 'variable_value' => $new, + ]); + + if ($original !== $new) { + + Activity::event('server:startup.edit') + ->property([ + 'variable' => $serverVariable->variable->env_variable, + 'old' => $original, + 'new' => $new, + ]) + ->log(); + } + + $powerRepository->setServer($server)->send('restart'); + + Notification::make() + ->title('GSL Token updated') + ->body('Restart the server to use the new token.') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Error') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public static function register(Application $app): self + { + return new self($app); + } +} diff --git a/app/Extensions/Features/JavaVersion.php b/app/Extensions/Features/JavaVersion.php new file mode 100644 index 000000000..d7f5b80d1 --- /dev/null +++ b/app/Extensions/Features/JavaVersion.php @@ -0,0 +1,97 @@ + */ + public function getListeners(): array + { + return [ + 'java.lang.UnsupportedClassVersionError', + 'minecraft 1.17 requires running the server with java 16 or above', + 'minecraft 1.18 requires running the server with java 17 or above', + 'unsupported major.minor version', + 'has been compiled by a more recent version of the java runtime', + ]; + } + + public function getId(): string + { + return 'java_version'; + } + + public function getAction(): Action + { + /** @var Server $server */ + $server = Filament::getTenant(); + + return Action::make($this->getId()) + ->requiresConfirmation() + ->modalHeading('Unsupported Java Version') + ->modalDescription('This server is currently running an unsupported version of Java and cannot be started.') + ->modalSubmitActionLabel('Update Docker Image') + ->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server)) + ->form([ + Placeholder::make('java') + ->label('Please select a supported version from the list below to continue starting the server.'), + Select::make('image') + ->label('Docker Image') + ->disabled(fn () => !in_array($server->image, $server->egg->docker_images)) + ->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value])) + ->selectablePlaceholder(false) + ->default(fn () => $server->image) + ->notIn(fn () => $server->image) + ->required() + ->preload() + ->native(false), + ]) + ->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server) { + try { + $new = $data['image']; + $original = $server->image; + $server->forceFill(['image' => $new])->saveOrFail(); + + if ($original !== $server->image) { + Activity::event('server:startup.image') + ->property(['old' => $original, 'new' => $new]) + ->log(); + } + $powerRepository->setServer($server)->send('restart'); + + Notification::make() + ->title('Docker image updated') + ->body('Restart the server to use the new image.') + ->success() + ->send(); + } catch (\Exception $e) { + Notification::make() + ->title('Error') + ->body($e->getMessage()) + ->danger() + ->send(); + } + }); + } + + public static function register(Application $app): self + { + return new self($app); + } +} diff --git a/app/Extensions/Features/MinecraftEula.php b/app/Extensions/Features/MinecraftEula.php new file mode 100644 index 000000000..7627f9fc3 --- /dev/null +++ b/app/Extensions/Features/MinecraftEula.php @@ -0,0 +1,72 @@ + */ + public function getListeners(): array + { + return [ + 'You need to agree to the EULA in order to run the server', + ]; + } + + public function getId(): string + { + return 'eula'; + } + + public function getAction(): Action + { + return Action::make($this->getId()) + ->requiresConfirmation() + ->modalHeading('Minecraft EULA') + ->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the Minecraft EULA '))) + ->modalSubmitActionLabel('I Accept') + ->action(function (DaemonFileRepository $fileRepository, DaemonPowerRepository $powerRepository) { + try { + /** @var Server $server */ + $server = Filament::getTenant(); + $content = $fileRepository->setServer($server)->getContent('eula.txt'); + $content = preg_replace('/(eula=)false/', '\1true', $content); + $fileRepository->setServer($server)->putContent('eula.txt', $content); + $powerRepository->setServer($server)->send('restart'); + + Notification::make() + ->title('Docker image updated') + ->body('Restart the server.') + ->success() + ->send(); + } catch (Exception $e) { + Notification::make() + ->title('Error') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } + ); + } + + public static function register(Application $app): self + { + return new self($app); + } +} diff --git a/app/Extensions/Features/PIDLimit.php b/app/Extensions/Features/PIDLimit.php new file mode 100644 index 000000000..dfae1d8c4 --- /dev/null +++ b/app/Extensions/Features/PIDLimit.php @@ -0,0 +1,76 @@ + */ + public function getListeners(): array + { + return [ + 'pthread_create failed', + 'failed to create thread', + 'unable to create thread', + 'unable to create native thread', + 'unable to create new native thread', + 'exception in thread "craft async scheduler management thread"', + ]; + } + + public function getId(): string + { + return 'pid_limit'; + } + + public function getAction(): Action + { + return Action::make($this->getId()) + ->requiresConfirmation() + ->icon('tabler-alert-triangle') + ->modalHeading(fn () => auth()->user()->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...') + ->modalDescription(new HtmlString(Blade::render( + auth()->user()->isAdmin() ? <<<'HTML' +

+ This server has reached the maximum process or memory limit. +

+

+ Increasing container_pid_limit in the wings + configuration, config.yml, might help resolve + this issue. +

+

+ Note: Wings must be restarted for the configuration file changes to take effect +

+ HTML + : + <<<'HTML' +

+ This server is attempting to use more resources than allocated. Please contact the administrator + and give them the error below. +

+

+ + pthread_create failed, Possibly out of memory or process/resource limits reached + +

+ HTML + ))) + ->modalCancelActionLabel('Close') + ->action(fn () => null); + } + + public static function register(Application $app): self + { + return new self($app); + } +} diff --git a/app/Extensions/Features/SteamDiskSpace.php b/app/Extensions/Features/SteamDiskSpace.php new file mode 100644 index 000000000..c4479a987 --- /dev/null +++ b/app/Extensions/Features/SteamDiskSpace.php @@ -0,0 +1,64 @@ + */ + public function getListeners(): array + { + return [ + 'steamcmd needs 250mb of free disk space to update', + '0x202 after update job', + ]; + } + + public function getId(): string + { + return 'steam_disk_space'; + } + + public function getAction(): Action + { + return Action::make($this->getId()) + ->requiresConfirmation() + ->modalHeading('Out of available disk space...') + ->modalDescription(new HtmlString(Blade::render( + auth()->user()->isAdmin() ? <<<'HTML' +

+ This server has run out of available disk space and cannot complete the install or update + process. +

+

+ Ensure the machine has enough disk space by typing{' '} + df -h on the machine hosting + this server. Delete files or increase the available disk space to resolve the issue. +

+ HTML + : + <<<'HTML' +

+ This server has run out of available disk space and cannot complete the install or update + process. Please get in touch with the administrator(s) and inform them of disk space issues. +

+ HTML + ))) + ->modalCancelActionLabel('Close') + ->action(fn () => null); + } + + public static function register(Application $app): self + { + return new self($app); + } +} diff --git a/app/Filament/Server/Pages/Console.php b/app/Filament/Server/Pages/Console.php index b909b665e..065482bfb 100644 --- a/app/Filament/Server/Pages/Console.php +++ b/app/Filament/Server/Pages/Console.php @@ -5,6 +5,7 @@ namespace App\Filament\Server\Pages; use App\Enums\ConsoleWidgetPosition; use App\Enums\ContainerStatus; use App\Exceptions\Http\Server\ServerStateConflictException; +use App\Extensions\Features\FeatureProvider; use App\Filament\Server\Widgets\ServerConsole; use App\Filament\Server\Widgets\ServerCpuChart; use App\Filament\Server\Widgets\ServerMemoryChart; @@ -13,8 +14,9 @@ use App\Filament\Server\Widgets\ServerOverview; use App\Livewire\AlertBanner; use App\Models\Permission; use App\Models\Server; -use Filament\Actions\Action; +use Filament\Actions\Concerns\InteractsWithActions; use Filament\Facades\Filament; +use Filament\Actions\Action; use Filament\Pages\Page; use Filament\Support\Enums\ActionSize; use Filament\Widgets\Widget; @@ -23,6 +25,8 @@ use Livewire\Attributes\On; class Console extends Page { + use InteractsWithActions; + protected static ?string $navigationIcon = 'tabler-brand-tabler'; protected static ?int $navigationSort = 1; @@ -47,6 +51,30 @@ class Console extends Page } } + public function boot(): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + /** @var FeatureProvider $feature */ + foreach ($server->egg->features() as $feature) { + $this->cacheAction($feature->getAction()); + } + } + + #[On('mount-feature')] + public function mountFeature(string $data): void + { + $data = json_decode($data); + $feature = data_get($data, 'key'); + + $feature = FeatureProvider::getProviders($feature); + if ($this->getMountedAction()) { + return; + } + $this->mountAction($feature->getId()); + sleep(2); // TODO find a better way + } + public function getWidgetData(): array { return [ @@ -126,33 +154,30 @@ class Console extends Page Action::make('start') ->color('primary') ->size(ActionSize::ExtraLarge) - ->action(fn () => $this->dispatch('setServerState', state: 'start', uuid: $server->uuid)) + ->dispatch('setServerState', ['state' => 'start', 'uuid' => $server->uuid]) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_START, $server)) ->disabled(fn () => $server->isInConflictState() || !$this->status->isStartable()) ->icon('tabler-player-play-filled'), Action::make('restart') ->color('gray') ->size(ActionSize::ExtraLarge) - ->action(fn () => $this->dispatch('setServerState', state: 'restart', uuid: $server->uuid)) + ->dispatch('setServerState', ['state' => 'restart', 'uuid' => $server->uuid]) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_RESTART, $server)) ->disabled(fn () => $server->isInConflictState() || !$this->status->isRestartable()) ->icon('tabler-reload'), Action::make('stop') ->color('danger') ->size(ActionSize::ExtraLarge) - ->action(fn () => $this->dispatch('setServerState', state: 'stop', uuid: $server->uuid)) + ->dispatch('setServerState', ['state' => 'stop', 'uuid' => $server->uuid]) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server)) ->hidden(fn () => $this->status->isStartingOrStopping() || $this->status->isKillable()) ->disabled(fn () => $server->isInConflictState() || !$this->status->isStoppable()) ->icon('tabler-player-stop-filled'), Action::make('kill') ->color('danger') - ->requiresConfirmation() - ->modalHeading('Do you wish to kill this server?') - ->modalDescription('This can result in data corruption and/or data loss!') - ->modalSubmitActionLabel('Kill Server') + ->tooltip('This can result in data corruption and/or data loss!') ->size(ActionSize::ExtraLarge) - ->action(fn () => $this->dispatch('setServerState', state: 'kill', uuid: $server->uuid)) + ->dispatch('setServerState', ['state' => 'kill', 'uuid' => $server->uuid]) ->authorize(fn () => auth()->user()->can(Permission::ACTION_CONTROL_STOP, $server)) ->hidden(fn () => $server->isInConflictState() || !$this->status->isKillable()) ->icon('tabler-alert-square'), diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 300371f25..f3091016b 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Contracts\Validatable; use App\Exceptions\Service\Egg\HasChildrenException; use App\Exceptions\Service\HasActiveServersException; +use App\Extensions\Features\FeatureProvider; use App\Traits\HasValidation; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -70,19 +71,6 @@ class Egg extends Model implements Validatable */ public const EXPORT_VERSION = 'PLCN_v1'; - /** - * Different features that can be enabled on any given egg. These are used internally - * to determine which types of frontend functionality should be shown to the user. Eggs - * will automatically inherit features from a parent egg if they are already configured - * to copy configuration values from said egg. - * - * To skip copying the features, an empty array value should be passed in ("[]") rather - * than leaving it null. - */ - public const FEATURE_EULA_POPUP = 'eula'; - - public const FEATURE_FASTDL = 'fastdl'; - /** * Fields that are not mass assignable. */ @@ -172,6 +160,12 @@ class Egg extends Model implements Validatable }); } + /** @return array */ + public function features(): array + { + return FeatureProvider::getProviders($this->features); + } + /** * Returns the install script for the egg; if egg is copying from another * it will return the copied script. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2dd341b8d..7ba52a820 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -15,6 +15,11 @@ use App\Extensions\Avatar\Providers\UiAvatarsProvider; use App\Extensions\OAuth\Providers\GitlabProvider; use App\Models; use App\Extensions\Captcha\Providers\TurnstileProvider; +use App\Extensions\Features\GSLToken; +use App\Extensions\Features\JavaVersion; +use App\Extensions\Features\MinecraftEula; +use App\Extensions\Features\PIDLimit; +use App\Extensions\Features\SteamDiskSpace; use App\Extensions\OAuth\Providers\AuthentikProvider; use App\Extensions\OAuth\Providers\CommonProvider; use App\Extensions\OAuth\Providers\DiscordProvider; @@ -121,6 +126,13 @@ class AppServiceProvider extends ServiceProvider GravatarProvider::register(); UiAvatarsProvider::register(); + // Default Feature providers + GSLToken::register($app); + JavaVersion::register($app); + MinecraftEula::register($app); + PIDLimit::register($app); + SteamDiskSpace::register($app); + FilamentColor::register([ 'danger' => Color::Red, 'gray' => Color::Zinc, diff --git a/app/Services/Servers/ServerConfigurationStructureService.php b/app/Services/Servers/ServerConfigurationStructureService.php index 541d07399..b0c8156c3 100644 --- a/app/Services/Servers/ServerConfigurationStructureService.php +++ b/app/Services/Servers/ServerConfigurationStructureService.php @@ -101,6 +101,9 @@ class ServerConfigurationStructureService 'egg' => [ 'id' => $server->egg->uuid, 'file_denylist' => $server->egg->inherit_file_denylist, + 'features' => collect($server->egg->features())->mapWithKeys(fn ($feature) => [ + $feature->getId() => $feature->getListeners(), + ])->all(), ], ]; diff --git a/app/Transformers/Api/Application/EggTransformer.php b/app/Transformers/Api/Application/EggTransformer.php index be342f24f..a416ed026 100644 --- a/app/Transformers/Api/Application/EggTransformer.php +++ b/app/Transformers/Api/Application/EggTransformer.php @@ -46,6 +46,7 @@ class EggTransformer extends BaseTransformer 'name' => $model->name, 'author' => $model->author, 'description' => $model->description, + 'features' => $model->features, // "docker_image" is deprecated, but left here to avoid breaking too many things at once // in external software. We'll remove it down the road once things have gotten the chance // to upgrade to using "docker_images". diff --git a/resources/views/filament/components/server-console.blade.php b/resources/views/filament/components/server-console.blade.php index a2e19f487..4ce34a40d 100644 --- a/resources/views/filament/components/server-console.blade.php +++ b/resources/views/filament/components/server-console.blade.php @@ -129,6 +129,9 @@ case 'install output': handleConsoleOutput(args[0]); break; + case 'feature match': + Livewire.dispatch('mount-feature', { data: args[0] }); + break; case 'status': handlePowerChangeEvent(args[0]); diff --git a/resources/views/filament/server/pages/console.blade.php b/resources/views/filament/server/pages/console.blade.php index 2deb74c68..7e3f6250c 100644 --- a/resources/views/filament/server/pages/console.blade.php +++ b/resources/views/filament/server/pages/console.blade.php @@ -4,4 +4,7 @@ :data="$this->getWidgetData()" :widgets="$this->getVisibleWidgets()" /> + + +