Merge branch 'main' of github.com:pelican-dev/panel

This commit is contained in:
Lance Pioch 2024-05-15 13:24:44 -04:00
commit b513366f35
70 changed files with 2885 additions and 804 deletions

View File

@ -13,12 +13,8 @@ LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=sqlite
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_STORE=file CACHE_STORE=file
QUEUE_CONNECTION=sync QUEUE_CONNECTION=database
SESSION_DRIVER=file SESSION_DRIVER=file
HASHIDS_SALT= HASHIDS_SALT=

2
.github/FUNDING.yml vendored
View File

@ -1,2 +1,2 @@
github: pelican-dev github: pelican-dev
custom: [https://buy.stripe.com/14kdU99SI4UT7ni9AB, https://buy.stripe.com/14kaHXc0Q9b9372eUU] open_collective: pelican-panel

View File

@ -11,23 +11,20 @@ class AppSettingsCommand extends Command
{ {
use EnvironmentWriterTrait; use EnvironmentWriterTrait;
public const CACHE_DRIVERS = [ public const CACHE_DRIVERS = [
'redis' => 'Redis',
'memcached' => 'Memcached',
'file' => 'Filesystem (recommended)', 'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
]; ];
public const SESSION_DRIVERS = [ public const SESSION_DRIVERS = [
'redis' => 'Redis',
'memcached' => 'Memcached',
'database' => 'MySQL Database',
'file' => 'Filesystem (recommended)', 'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
'database' => 'MySQL Database',
'cookie' => 'Cookie', 'cookie' => 'Cookie',
]; ];
public const QUEUE_DRIVERS = [ public const QUEUE_DRIVERS = [
'database' => 'MySQL Database (recommended)',
'redis' => 'Redis', 'redis' => 'Redis',
'database' => 'MySQL Database',
'sync' => 'Sync (recommended)',
]; ];
protected $description = 'Configure basic environment settings for the Panel.'; protected $description = 'Configure basic environment settings for the Panel.';
@ -86,7 +83,7 @@ class AppSettingsCommand extends Command
array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null
); );
$selected = config('queue.default', 'sync'); $selected = config('queue.default', 'database');
$this->variables['QUEUE_CONNECTION'] = $this->option('queue') ?? $this->choice( $this->variables['QUEUE_CONNECTION'] = $this->option('queue') ?? $this->choice(
'Queue Driver', 'Queue Driver',
self::QUEUE_DRIVERS, self::QUEUE_DRIVERS,

View File

@ -31,7 +31,7 @@ class EmailSettingsCommand extends Command
*/ */
public function handle(): void public function handle(): void
{ {
$this->variables['MAIL_DRIVER'] = $this->option('driver') ?? $this->choice( $this->variables['MAIL_MAILER'] = $this->option('driver') ?? $this->choice(
trans('command/messages.environment.mail.ask_driver'), trans('command/messages.environment.mail.ask_driver'),
[ [
'log' => 'Log', 'log' => 'Log',
@ -41,10 +41,10 @@ class EmailSettingsCommand extends Command
'mandrill' => 'Mandrill', 'mandrill' => 'Mandrill',
'postmark' => 'Postmark', 'postmark' => 'Postmark',
], ],
'smtp', env('MAIL_MAILER', env('MAIL_DRIVER', 'smtp')),
); );
$method = 'setup' . studly_case($this->variables['MAIL_DRIVER']) . 'DriverVariables'; $method = 'setup' . studly_case($this->variables['MAIL_MAILER']) . 'DriverVariables';
if (method_exists($this, $method)) { if (method_exists($this, $method)) {
$this->{$method}(); $this->{$method}();
} }

View File

@ -7,9 +7,7 @@ use Filament\Notifications\Notification;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag; use Prologue\Alerts\AlertsMessageBag;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;

View File

@ -13,6 +13,10 @@ class ApiKeyResource extends Resource
protected static ?string $model = ApiKey::class; protected static ?string $model = ApiKey::class;
protected static ?string $label = 'API Key'; protected static ?string $label = 'API Key';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-key'; protected static ?string $navigationIcon = 'tabler-key';
public static function canEdit($record): bool public static function canEdit($record): bool

View File

@ -10,6 +10,11 @@ class DatabaseHostResource extends Resource
{ {
protected static ?string $model = DatabaseHost::class; protected static ?string $model = DatabaseHost::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $label = 'Databases'; protected static ?string $label = 'Databases';
protected static ?string $navigationIcon = 'tabler-database'; protected static ?string $navigationIcon = 'tabler-database';

View File

@ -22,50 +22,59 @@ class CreateDatabaseHost extends CreateRecord
{ {
return $form return $form
->schema([ ->schema([
Section::make()->schema([ Section::make()
Forms\Components\TextInput::make('host') ->columns([
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.') 'default' => 2,
->required() 'sm' => 3,
->live() 'md' => 3,
->debounce(500) 'lg' => 4,
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state)) ])
->maxLength(191), ->schema([
Forms\Components\TextInput::make('port') Forms\Components\TextInput::make('host')
->helperText('The port that MySQL is running on for this host.') ->columnSpan(2)
->required() ->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->numeric() ->required()
->default(3306) ->live(onBlur: true)
->minValue(0) ->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxValue(65535), ->maxLength(191),
Forms\Components\TextInput::make('username') Forms\Components\TextInput::make('port')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.') ->columnSpan(1)
->required() ->helperText('The port that MySQL is running on for this host.')
->maxLength(191), ->required()
Forms\Components\TextInput::make('password') ->numeric()
->helperText('The password for the database user.') ->default(3306)
->password() ->minValue(0)
->revealable() ->maxValue(65535),
->maxLength(191) Forms\Components\TextInput::make('max_databases')
->required(), ->label('Max databases')
Forms\Components\TextInput::make('name') ->helpertext('Blank is unlimited.')
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.') ->numeric(),
->required() Forms\Components\TextInput::make('name')
->maxLength(60), ->label('Display Name')
Forms\Components\Select::make('node_id') ->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
->searchable() ->required()
->preload() ->maxLength(60),
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.') Forms\Components\TextInput::make('username')
->label('Linked Node') ->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->relationship('node', 'name'), ->required()
])->columns([ ->maxLength(191),
'default' => 1, Forms\Components\TextInput::make('password')
'lg' => 2, ->helperText('The password for the database user.')
]), ->password()
->revealable()
->maxLength(191)
->required(),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
]),
]); ]);
} }
protected function mutateFormDataBeforeSave(array $data): array protected function mutateFormDataBeforeCreate(array $data): array
{ {
if (isset($data['password'])) { if (isset($data['password'])) {
$data['password'] = encrypt($data['password']); $data['password'] = encrypt($data['password']);
@ -73,4 +82,17 @@ class CreateDatabaseHost extends CreateRecord
return $data; return $data;
} }
protected function getHeaderActions(): array
{
return [
$this->getCreateFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
} }

View File

@ -17,45 +17,54 @@ class EditDatabaseHost extends EditRecord
{ {
return $form return $form
->schema([ ->schema([
Section::make()->schema([ Section::make()
Forms\Components\TextInput::make('host') ->columns([
->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.') 'default' => 2,
->required() 'sm' => 3,
->live() 'md' => 3,
->debounce(500) 'lg' => 4,
->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state)) ])
->maxLength(191), ->schema([
Forms\Components\TextInput::make('port') Forms\Components\TextInput::make('host')
->helperText('The port that MySQL is running on for this host.') ->columnSpan(2)
->required() ->helperText('The IP address or Domain name that should be used when attempting to connect to this MySQL host from this Panel to create new databases.')
->numeric() ->required()
->default(3306) ->live(onBlur: true)
->minValue(0) ->afterStateUpdated(fn ($state, Forms\Set $set) => $set('name', $state))
->maxValue(65535), ->maxLength(191),
Forms\Components\TextInput::make('username') Forms\Components\TextInput::make('port')
->helperText('The username of an account that has enough permissions to create new users and databases on the system.') ->columnSpan(1)
->required() ->helperText('The port that MySQL is running on for this host.')
->maxLength(191), ->required()
Forms\Components\TextInput::make('password') ->numeric()
->helperText('The password for the database user.') ->minValue(0)
->password() ->maxValue(65535),
->revealable() Forms\Components\TextInput::make('max_databases')
->maxLength(191) ->label('Max databases')
->required(), ->helpertext('Blank is unlimited.')
Forms\Components\TextInput::make('name') ->numeric(),
->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.') Forms\Components\TextInput::make('name')
->required() ->label('Display Name')
->maxLength(60), ->helperText('A short identifier used to distinguish this location from others. Must be between 1 and 60 characters, for example, us.nyc.lvl3.')
Forms\Components\Select::make('node_id') ->required()
->searchable() ->maxLength(60),
->preload() Forms\Components\TextInput::make('username')
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.') ->helperText('The username of an account that has enough permissions to create new users and databases on the system.')
->label('Linked Node') ->required()
->relationship('node', 'name'), ->maxLength(191),
])->columns([ Forms\Components\TextInput::make('password')
'default' => 1, ->helperText('The password for the database user.')
'lg' => 2, ->password()
]), ->revealable()
->maxLength(191)
->required(),
Forms\Components\Select::make('node_id')
->searchable()
->preload()
->helperText('This setting only defaults to this database host when adding a database to a server on the selected node.')
->label('Linked Node')
->relationship('node', 'name'),
]),
]); ]);
} }
@ -63,6 +72,7 @@ class EditDatabaseHost extends EditRecord
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
]; ];
} }
@ -74,4 +84,16 @@ class EditDatabaseHost extends EditRecord
return $data; return $data;
} }
protected function getFormActions(): array
{
return [];
}
public function getRelationManagers(): array
{
return [
DatabaseHostResource\RelationManagers\DatabasesRelationManager::class,
];
}
} }

View File

@ -12,6 +12,8 @@ class ListDatabaseHosts extends ListRecords
{ {
protected static string $resource = DatabaseHostResource::class; protected static string $resource = DatabaseHostResource::class;
protected ?string $heading = 'Database Hosts';
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
@ -48,7 +50,7 @@ class ListDatabaseHosts extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make('create')->label('New Database Host'),
]; ];
} }
} }

View File

@ -0,0 +1,51 @@
<?php
namespace App\Filament\Resources\DatabaseHostResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class DatabasesRelationManager extends RelationManager
{
protected static string $relationship = 'databases';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('database')->columnSpanFull(),
Forms\Components\TextInput::make('username'),
Forms\Components\TextInput::make('password')->default('Soon™'),
Forms\Components\TextInput::make('remote')->label('Connections From'),
Forms\Components\TextInput::make('max_connections'),
Forms\Components\TextInput::make('JDBC')->label('JDBC Connection String')->columnSpanFull()->default('Soon™'),
Forms\Components\TextInput::make('created_at'),
Forms\Components\TextInput::make('updated_at'),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('servers')
->columns([
Tables\Columns\TextColumn::make('database'),
Tables\Columns\TextColumn::make('username'),
//Tables\Columns\TextColumn::make('password'),
Tables\Columns\TextColumn::make('remote'),
Tables\Columns\TextColumn::make('server_id')
->label('Belongs To'),
// TODO ->url(route('filament.admin.resources.servers.edit', ['record', ''])),
Tables\Columns\TextColumn::make('max_connections'),
Tables\Columns\TextColumn::make('created_at'),
])
->actions([
Tables\Actions\DeleteAction::make(),
Tables\Actions\ViewAction::make(),
//Tables\Actions\EditAction::make(),
]);
}
}

View File

@ -10,6 +10,11 @@ class DatabaseResource extends Resource
{ {
protected static ?string $model = Database::class; protected static ?string $model = Database::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-database'; protected static ?string $navigationIcon = 'tabler-database';
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;

View File

@ -10,6 +10,10 @@ class EggResource extends Resource
{ {
protected static ?string $model = Egg::class; protected static ?string $model = Egg::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-eggs'; protected static ?string $navigationIcon = 'tabler-eggs';
protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordTitleAttribute = 'name';

View File

@ -25,12 +25,16 @@ class EditEgg extends EditRecord
Forms\Components\TextInput::make('name') Forms\Components\TextInput::make('name')
->required() ->required()
->maxLength(191) ->maxLength(191)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1])
->helperText('A simple, human-readable name to use as an identifier for this Egg.'), ->helperText('A simple, human-readable name to use as an identifier for this Egg.'),
Forms\Components\TextInput::make('uuid') Forms\Components\TextInput::make('uuid')
->label('Egg UUID')
->disabled() ->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'), ->helperText('This is the globally unique identifier for this Egg which Wings uses as an identifier.'),
Forms\Components\TextInput::make('id')
->label('Egg ID')
->disabled(),
Forms\Components\Textarea::make('description') Forms\Components\Textarea::make('description')
->rows(3) ->rows(3)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]) ->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
@ -203,6 +207,19 @@ class EditEgg extends EditRecord
->color('primary') ->color('primary')
// TODO uses old admin panel export service // TODO uses old admin panel export service
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg['id']])), ->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg['id']])),
$this->getSaveFormAction()->formId('form'),
];
}
protected function getFormActions(): array
{
return [];
}
public function getRelationManagers(): array
{
return [
EggResource\RelationManagers\ServersRelationManager::class,
]; ];
} }
} }

View File

@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\EggResource\RelationManagers;
use App\Models\Server;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class ServersRelationManager extends RelationManager
{
protected static string $relationship = 'servers';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('servers')
->emptyStateDescription('No Servers')->emptyStateHeading('No servers are assigned this egg.')
->searchable(false)
->columns([
Tables\Columns\TextColumn::make('user.username')
->label('Owner')
->icon('tabler-user')
->url(fn (Server $server): string => route('filament.admin.resources.users.edit', ['record' => $server->user]))
->sortable(),
Tables\Columns\TextColumn::make('name')
->icon('tabler-brand-docker')
->url(fn (Server $server): string => route('filament.admin.resources.servers.edit', ['record' => $server]))
->sortable(),
Tables\Columns\TextColumn::make('node.name')
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node])),
Tables\Columns\TextColumn::make('image')
->label('Docker Image'),
Tables\Columns\SelectColumn::make('allocation.id')
->label('Primary Allocation')
->options(fn ($state, Server $server) => [$server->allocation->id => $server->allocation->address])
->selectablePlaceholder(false)
->sortable(),
]);
}
}

View File

@ -10,6 +10,11 @@ class MountResource extends Resource
{ {
protected static ?string $model = Mount::class; protected static ?string $model = Mount::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-layers-linked'; protected static ?string $navigationIcon = 'tabler-layers-linked';
public static function getRelations(): array public static function getRelations(): array

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources\MountResource\Pages; namespace App\Filament\Resources\MountResource\Pages;
use App\Filament\Resources\MountResource; use App\Filament\Resources\MountResource;
use App\Services\Servers\ServerCreationService;
use Filament\Forms\Components\Group; use Filament\Forms\Components\Group;
use Filament\Forms\Components\Section; use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;

View File

@ -98,6 +98,12 @@ class EditMount extends EditRecord
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
} }

View File

@ -11,6 +11,11 @@ class NodeResource extends Resource
{ {
protected static ?string $model = Node::class; protected static ?string $model = Node::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-server-2'; protected static ?string $navigationIcon = 'tabler-server-2';
protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordTitleAttribute = 'name';

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource; use App\Filament\Resources\NodeResource;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\Tabs;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\HtmlString; use Illuminate\Support\HtmlString;
@ -17,179 +18,298 @@ class CreateNode extends CreateRecord
public function form(Forms\Form $form): Forms\Form public function form(Forms\Form $form): Forms\Form
{ {
return $form return $form->schema([
->columns([ Tabs::make('Tabs')
'default' => 2, ->columns([
'sm' => 3, 'default' => 2,
'md' => 3, 'sm' => 3,
'lg' => 4, 'md' => 3,
]) 'lg' => 4,
->schema([ ])
Forms\Components\TextInput::make('fqdn') ->persistTabInQueryString()
->columnSpan(2) ->columnSpanFull()
->required() ->tabs([
->autofocus() Tabs\Tab::make('Basic Settings')
->live(debounce: 1500) ->icon('tabler-server')
->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure()) ->schema([
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name') Forms\Components\TextInput::make('fqdn')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com') ->columnSpan(2)
->helperText(function ($state) { ->required()
if (is_ip($state)) { ->autofocus()
if (request()->isSecure()) { ->live(debounce: 1500)
return ' ->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too. Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses You must use a domain name, because you cannot get SSL certificates for IP Addresses
'; ';
} }
return ''; return '';
} }
return " return "
This is the domain name that points to your node's IP Address. This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field! If you've already set up this, you can verify it by checking the next field!
"; ";
}) })
->hintColor('danger') ->hintColor('danger')
->hint(function ($state) { ->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) { if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL'; return 'You cannot connect to an IP Address over SSL';
} }
return ''; return '';
}) })
->afterStateUpdated(function (Forms\Set $set, ?string $state) { ->afterStateUpdated(function (Forms\Set $set, ?string $state) {
$set('dns', null); $set('dns', null);
$set('ip', null); $set('ip', null);
[$subdomain] = str($state)->explode('.', 2); [$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) { if (!is_numeric($subdomain)) {
$set('name', $subdomain); $set('name', $subdomain);
} }
if (!$state || is_ip($state)) { if (!$state || is_ip($state)) {
$set('dns', null); $set('dns', null);
return; return;
} }
$validRecords = gethostbynamel($state); $validRecords = gethostbynamel($state);
if ($validRecords) { if ($validRecords) {
$set('dns', true); $set('dns', true);
$set('ip', collect($validRecords)->first()); $set('ip', collect($validRecords)->first());
return; return;
} }
$set('dns', false); $set('dns', false);
}) })
->maxLength(191), ->maxLength(191),
Forms\Components\TextInput::make('ip') Forms\Components\TextInput::make('ip')
->disabled() ->disabled()
->hidden(), ->hidden(),
Forms\Components\ToggleButtons::make('dns') Forms\Components\ToggleButtons::make('dns')
->label('DNS Record Check') ->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.') ->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->disabled() ->disabled()
->inline() ->inline()
->default(null) ->default(null)
->hint(fn (Forms\Get $get) => $get('ip')) ->hint(fn (Forms\Get $get) => $get('ip'))
->hintColor('success') ->hintColor('success')
->options([ ->options([
true => 'Valid', true => 'Valid',
false => 'Invalid', false => 'Invalid',
]) ])
->colors([ ->colors([
true => 'success', true => 'success',
false => 'danger', false => 'danger',
]) ])
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]), ]),
Forms\Components\TextInput::make('daemon_listen') Forms\Components\TextInput::make('daemon_listen')
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]) ])
->label(trans('strings.port')) ->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.') ->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->minValue(0) ->minValue(0)
->maxValue(65536) ->maxValue(65536)
->default(8080) ->default(8080)
->required() ->required()
->integer(), ->integer(),
Forms\Components\TextInput::make('name') Forms\Components\TextInput::make('name')
->label('Display Name') ->label('Display Name')
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
'md' => 1, 'md' => 1,
'lg' => 2, 'lg' => 2,
]) ])
->required() ->required()
->regex('/[a-zA-Z0-9_\.\- ]+/') ->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.') ->helperText('This name is for display only and can be changed later.')
->maxLength(100), ->maxLength(100),
Forms\Components\ToggleButtons::make('scheme') Forms\Components\ToggleButtons::make('scheme')
->label('Communicate over SSL') ->label('Communicate over SSL')
->columnSpan([ ->columnSpan([
'default' => 1, 'default' => 1,
'sm' => 1, 'sm' => 1,
'md' => 1, 'md' => 1,
'lg' => 1, 'lg' => 1,
]) ])
->required() ->required()
->inline() ->inline()
->helperText(function (Forms\Get $get) { ->helperText(function (Forms\Get $get) {
if (request()->isSecure()) { if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.'); return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
} }
if (is_ip($get('fqdn'))) { if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.'; return 'An IP address cannot use SSL.';
} }
return ''; return '';
}) })
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure()) ->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([ ->options([
'http' => 'HTTP', 'http' => 'HTTP',
'https' => 'HTTPS (SSL)', 'https' => 'HTTPS (SSL)',
]) ])
->colors([ ->colors([
'http' => 'warning', 'http' => 'warning',
'https' => 'success', 'https' => 'success',
]) ])
->icons([ ->icons([
'http' => 'tabler-lock-open-off', 'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock', 'https' => 'tabler-lock',
]) ])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ->default(fn () => request()->isSecure() ? 'https' : 'http'),
]),
Forms\Components\Textarea::make('description') Tabs\Tab::make('Advanced Settings')
->label('strings.description') ->icon('tabler-server-cog')
->hidden() ->schema([
->columnSpan([ Forms\Components\TextInput::make('upload_size')
'default' => 1, ->label('Upload Limit')
'sm' => 1, ->helperText('Enter the maximum size of files that can be uploaded through the web-based file manager.')
'md' => 2, ->columnSpan(1)
'lg' => 4, ->numeric()->required()
]) ->default(256)
->rows(5), ->minValue(1)
->maxValue(1024)
Forms\Components\Hidden::make('skipValidation')->default(true), ->suffix('MiB'),
]); Forms\Components\ToggleButtons::make('public')
->label('Automatic Allocation')->inline()
->default(true)
->columnSpan(1)
->options([
true => 'Yes',
false => 'No',
])
->colors([
true => 'success',
false => 'danger',
]),
Forms\Components\ToggleButtons::make('maintenance_mode')
->label('Maintenance Mode')->inline()
->columnSpan(1)
->default(false)
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->options([
true => 'Enable',
false => 'Disable',
])
->colors([
true => 'danger',
false => 'success',
]),
Forms\Components\TagsInput::make('tags')
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->columnSpan(1),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 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')
->columnSpan(2)
->numeric()
->minValue(0),
Forms\Components\TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 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 Limit')->inlineLabel()
->suffix('MiB')
->columnSpan(2)
->numeric()
->minValue(0),
Forms\Components\TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
]),
]),
]);
} }
protected function getRedirectUrlParameters(): array protected function getRedirectUrlParameters(): array

View File

@ -32,13 +32,293 @@ class EditNode extends EditRecord
->tabs([ ->tabs([
Tabs\Tab::make('Basic Settings') Tabs\Tab::make('Basic Settings')
->icon('tabler-server') ->icon('tabler-server')
->schema((new CreateNode())->form($form)->getComponents()), ->schema([
// Tabs\Tab::make('Advanced Settings') Forms\Components\TextInput::make('fqdn')
// ->icon('tabler-server-cog') ->columnSpan(2)
// ->schema([ ->required()
// Forms\Components\Placeholder::make('Coming soon!'), ->autofocus()
// ]), ->live(debounce: 1500)
Tabs\Tab::make('Configuration') ->rule('prohibited', fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? 'IP Address' : 'Domain Name')
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
if (is_ip($state)) {
if (request()->isSecure()) {
return '
Your panel is currently secured via an SSL certificate and that means your nodes require one too.
You must use a domain name, because you cannot get SSL certificates for IP Addresses
';
}
return '';
}
return "
This is the domain name that points to your node's IP Address.
If you've already set up this, you can verify it by checking the next field!
";
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return 'You cannot connect to an IP Address over SSL';
}
return '';
})
->afterStateUpdated(function (Forms\Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$validRecords = gethostbynamel($state);
if ($validRecords) {
$set('dns', true);
$set('ip', collect($validRecords)->first());
return;
}
$set('dns', false);
})
->maxLength(191),
Forms\Components\TextInput::make('ip')
->disabled()
->hidden(),
Forms\Components\ToggleButtons::make('dns')
->label('DNS Record Check')
->helperText('This lets you know if your DNS record correctly points to an IP Address.')
->disabled()
->inline()
->default(null)
->hint(fn (Forms\Get $get) => $get('ip'))
->hintColor('success')
->options([
true => 'Valid',
false => 'Invalid',
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Forms\Components\TextInput::make('daemon_listen')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->label(trans('strings.port'))
->helperText('If you are running the daemon behind Cloudflare you should set the daemon port to 8443 to allow websocket proxying over SSL.')
->minValue(0)
->maxValue(65536)
->default(8080)
->required()
->integer(),
Forms\Components\TextInput::make('name')
->label('Display Name')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->required()
->regex('/[a-zA-Z0-9_\.\- ]+/')
->helperText('This name is for display only and can be changed later.')
->maxLength(100),
Forms\Components\ToggleButtons::make('scheme')
->label('Communicate over SSL')
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->inline()
->helperText(function (Forms\Get $get) {
if (request()->isSecure()) {
return new HtmlString('Your Panel is using a secure SSL connection,<br>so your Daemon must too.');
}
if (is_ip($get('fqdn'))) {
return 'An IP address cannot use SSL.';
}
return '';
})
->disableOptionWhen(fn (string $value): bool => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
])
->colors([
'http' => 'warning',
'https' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http'), ]),
Tabs\Tab::make('Advanced Settings')
->icon('tabler-server-cog')
->schema([
Forms\Components\TextInput::make('id')
->label('Node ID')
->disabled(),
Forms\Components\TextInput::make('uuid')
->label('Node UUID')
->hintAction(CopyAction::make())
->columnSpan(2)
->disabled(),
Forms\Components\TagsInput::make('tags')
->label('Tags')
->disabled()
->placeholder('Not Implemented')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Not Implemented')
->columnSpan(1),
Forms\Components\ToggleButtons::make('public')
->label('Automatic Allocation')->inline()
->columnSpan(1)
->options([
true => 'Yes',
false => 'No',
])
->colors([
true => 'success',
false => 'danger',
]),
Forms\Components\ToggleButtons::make('maintenance_mode')
->label('Maintenance Mode')->inline()
->columnSpan(1)
->hinticon('tabler-question-mark')
->hintIconTooltip("If the node is marked 'Under Maintenance' users won't be able to access servers that are on this node.")
->options([
true => 'Enable',
false => 'Disable',
])
->colors([
true => 'danger',
false => 'success',
]),
Forms\Components\TextInput::make('upload_size')
->label('Upload Limit')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Enter the maximum size of files that can be uploaded through the web-based file manager.')
->columnStart(4)->columnSpan(1)
->numeric()->required()
->minValue(1)
->maxValue(1024)
->suffix('MiB'),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_mem')
->label('Memory')->inlineLabel()->inline()
->afterStateUpdated(fn (Forms\Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('memory_overallocate', 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\TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label('Overallocate')->inlineLabel()
->required()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
Forms\Components\Grid::make()
->columns(6)
->columnSpanFull()
->schema([
Forms\Components\ToggleButtons::make('unlimited_disk')
->label('Disk')->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Forms\Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Forms\Set $set) => $set('disk_overallocate', 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 Limit')->inlineLabel()
->suffix('MiB')
->required()
->columnSpan(2)
->numeric()
->minValue(0),
Forms\Components\TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Overallocate')->inlineLabel()
->hintIcon('tabler-question-mark')
->hintIconTooltip('The % allowable to go over the set limit.')
->columnSpan(2)
->required()
->numeric()
->minValue(-1)
->maxValue(100)
->suffix('%'),
]),
]),
Tabs\Tab::make('Configuration File')
->icon('tabler-code') ->icon('tabler-code')
->schema([ ->schema([
Forms\Components\Placeholder::make('instructions') Forms\Components\Placeholder::make('instructions')
@ -66,18 +346,17 @@ class EditNode extends EditRecord
return $data; return $data;
} }
protected function getSteps(): array protected function getFormActions(): array
{ {
return [ return [];
];
} }
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make() Actions\DeleteAction::make()
->disabled(fn (Node $node) => $node->servers()->count() > 0) ->disabled(fn (Node $node) => $node->servers()->count() > 0)
->label(fn (Node $node) => $node->servers()->count() > 0 ? 'Node Has Servers' : 'Delete'), ->label(fn (Node $node) => $node->servers()->count() > 0 ? 'Node Has Servers' : 'Delete'),
$this->getSaveFormAction()->formId('form'),
]; ];
} }

View File

@ -42,15 +42,15 @@ class ListNodes extends ListRecords
->visibleFrom('sm') ->visibleFrom('sm')
->icon('tabler-device-desktop-analytics') ->icon('tabler-device-desktop-analytics')
->numeric() ->numeric()
->suffix(' GB') ->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2)) ->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('disk') Tables\Columns\TextColumn::make('disk')
->visibleFrom('sm') ->visibleFrom('sm')
->icon('tabler-file') ->icon('tabler-file')
->numeric() ->numeric()
->suffix(' GB') ->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1000, 2)) ->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->sortable(), ->sortable(),
Tables\Columns\IconColumn::make('scheme') Tables\Columns\IconColumn::make('scheme')
->visibleFrom('xl') ->visibleFrom('xl')

View File

@ -43,10 +43,13 @@ class AllocationsRelationManager extends RelationManager
Tables\Columns\TextColumn::make('server.name') Tables\Columns\TextColumn::make('server.name')
->label('Server') ->label('Server')
->icon('tabler-brand-docker') ->icon('tabler-brand-docker')
->searchable()
->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''), ->url(fn (Allocation $allocation): string => $allocation->server ? route('filament.admin.resources.servers.edit', ['record' => $allocation->server]) : ''),
Tables\Columns\TextColumn::make('ip_alias') Tables\Columns\TextInputColumn::make('ip_alias')
->searchable()
->label('Alias'), ->label('Alias'),
Tables\Columns\TextColumn::make('ip') Tables\Columns\TextInputColumn::make('ip')
->searchable()
->label('IP'), ->label('IP'),
Tables\Columns\TextColumn::make('port') Tables\Columns\TextColumn::make('port')
->searchable() ->searchable()
@ -126,6 +129,8 @@ class AllocationsRelationManager extends RelationManager
$ports = $sortedPorts; $ports = $sortedPorts;
} }
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) { if ($update) {
$set('allocation_ports', $ports->all()); $set('allocation_ports', $ports->all());
} }

View File

@ -40,8 +40,8 @@ class NodeMemoryChart extends ChartWidget
/** @var Node $node */ /** @var Node $node */
$node = $this->record; $node = $this->record;
$total = $node->statistics()['memory_total'] ?? 0; $total = ($node->statistics()['memory_total'] ?? 0) / 1024 / 1024 / 1024;
$used = $node->statistics()['memory_used'] ?? 0; $used = ($node->statistics()['memory_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used; $unused = $total - $used;
return [ return [

View File

@ -40,8 +40,8 @@ class NodeStorageChart extends ChartWidget
/** @var Node $node */ /** @var Node $node */
$node = $this->record; $node = $this->record;
$total = $node->statistics()['disk_total'] ?? 0; $total = ($node->statistics()['disk_total'] ?? 0) / 1024 / 1024 / 1024;
$used = $node->statistics()['disk_used'] ?? 0; $used = ($node->statistics()['disk_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used; $unused = $total - $used;
return [ return [

View File

@ -10,6 +10,11 @@ class ServerResource extends Resource
{ {
protected static ?string $model = Server::class; protected static ?string $model = Server::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-brand-docker'; protected static ?string $navigationIcon = 'tabler-brand-docker';
protected static ?string $recordTitleAttribute = 'name'; protected static ?string $recordTitleAttribute = 'name';

View File

@ -487,11 +487,12 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) ->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel() ->label('Memory Limit')->inlineLabel()
->suffix('MB') ->suffix('MiB')
->default(0) ->default(0)
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -517,11 +518,12 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) ->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel() ->label('Disk Space Limit')->inlineLabel()
->suffix('MB') ->suffix('MiB')
->default(0) ->default(0)
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -551,7 +553,9 @@ class CreateServer extends CreateRecord
->default(0) ->default(0)
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0)
->helperText('100% equals one logical thread'),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -593,7 +597,7 @@ class CreateServer extends CreateRecord
}) })
->label('Swap Memory') ->label('Swap Memory')
->default(0) ->default(0)
->suffix('MB') ->suffix('MiB')
->minValue(-1) ->minValue(-1)
->columnSpan(2) ->columnSpan(2)
->inlineLabel() ->inlineLabel()
@ -610,7 +614,7 @@ class CreateServer extends CreateRecord
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('oom_disabled') Forms\Components\ToggleButtons::make('oom_killer')
->label('OOM Killer') ->label('OOM Killer')
->inlineLabel()->inline() ->inlineLabel()->inline()
->default(false) ->default(false)

View File

@ -244,35 +244,57 @@ class EditServer extends EditRecord
])) ]))
->schema([ ->schema([
Forms\Components\Repeater::make('server_variables') Forms\Components\Repeater::make('server_variables')
->label('')
->relationship('serverVariables') ->relationship('serverVariables')
->grid() ->grid()
->deletable(false) ->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
->addable(false) foreach ($data as $key => $value) {
->schema([ if (!isset($data['variable_value'])) {
Forms\Components\TextInput::make('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([ ->rules([
fn (ServerVariable $variable): Closure => function (string $attribute, $value, Closure $fail) use ($variable) { fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [ $validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $variable->variable->rules, 'validatorkey' => $serverVariable->variable->rules,
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $variable->variable->name); $message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message); $fail($message);
} }
}, },
]) ]);
->label(fn (ServerVariable $variable) => $variable->variable->name)
->hintIcon('tabler-code')
->hintIconTooltip(fn (ServerVariable $variable) => $variable->variable->rules)
->prefix(fn (ServerVariable $variable) => '{{' . $variable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $variable) => $variable->variable->description ?: '—')
->maxLength(191),
Forms\Components\Hidden::make('variable_id'), $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), ->columnSpan(2),
]), ]),
@ -311,10 +333,11 @@ class EditServer extends EditRecord
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem')) ->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel() ->label('Memory Limit')->inlineLabel()
->suffix('MB') ->suffix('MiB')
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -340,10 +363,11 @@ class EditServer extends EditRecord
->dehydratedWhenHidden() ->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk')) ->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel() ->label('Disk Space Limit')->inlineLabel()
->suffix('MB') ->suffix('MiB')
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -372,7 +396,8 @@ class EditServer extends EditRecord
->suffix('%') ->suffix('%')
->required() ->required()
->columnSpan(2) ->columnSpan(2)
->numeric(), ->numeric()
->minValue(0),
]), ]),
Forms\Components\Grid::make() Forms\Components\Grid::make()
@ -417,7 +442,7 @@ class EditServer extends EditRecord
'limited', false => false, 'limited', false => false,
}) })
->label('Swap Memory')->inlineLabel() ->label('Swap Memory')->inlineLabel()
->suffix('MB') ->suffix('MiB')
->minValue(-1) ->minValue(-1)
->columnSpan(2) ->columnSpan(2)
->required() ->required()
@ -432,7 +457,7 @@ class EditServer extends EditRecord
->columns(4) ->columns(4)
->columnSpanFull() ->columnSpanFull()
->schema([ ->schema([
Forms\Components\ToggleButtons::make('oom_disabled') Forms\Components\ToggleButtons::make('oom_killer')
->label('OOM Killer')->inlineLabel()->inline() ->label('OOM Killer')->inlineLabel()->inline()
->columnSpan(2) ->columnSpan(2)
->options([ ->options([
@ -487,13 +512,6 @@ class EditServer extends EditRecord
->color('danger') ->color('danger')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server)) ->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server))
->requiresConfirmation(), ->requiresConfirmation(),
Actions\DeleteAction::make('Force Delete')
->label('Force Delete')
->hidden()
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->withForce()->handle($server))
->requiresConfirmation(),
Actions\Action::make('console') Actions\Action::make('console')
->label('Console') ->label('Console')
->icon('tabler-terminal') ->icon('tabler-terminal')
@ -520,4 +538,35 @@ class EditServer extends EditRecord
ServerResource\RelationManagers\AllocationsRelationManager::class, 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();
}
} }

View File

@ -31,7 +31,7 @@ class AllocationsRelationManager extends RelationManager
// ->actions // ->actions
// ->groups // ->groups
->columns([ ->columns([
Tables\Columns\TextColumn::make('ip_alias')->label('Alias'), Tables\Columns\TextInputColumn::make('ip_alias')->label('Alias'),
Tables\Columns\TextColumn::make('ip')->label('IP'), Tables\Columns\TextColumn::make('ip')->label('IP'),
Tables\Columns\TextColumn::make('port')->label('Port'), Tables\Columns\TextColumn::make('port')->label('Port'),
Tables\Columns\IconColumn::make('primary') Tables\Columns\IconColumn::make('primary')
@ -56,8 +56,8 @@ class AllocationsRelationManager extends RelationManager
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'), ->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\CreateAction::make()->label('Create Allocation'), //TODO Tables\Actions\CreateAction::make()->label('Create Allocation'),
//Tables\Actions\AssociateAction::make()->label('Add Allocation'), //TODO Tables\Actions\AssociateAction::make()->label('Add Allocation'),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([ Tables\Actions\BulkActionGroup::make([

View File

@ -11,6 +11,10 @@ class UserResource extends Resource
{ {
protected static ?string $model = User::class; protected static ?string $model = User::class;
public static function getNavigationBadge(): ?string
{
return static::getModel()::count();
}
protected static ?string $navigationIcon = 'tabler-users'; protected static ?string $navigationIcon = 'tabler-users';
protected static ?string $recordTitleAttribute = 'username'; protected static ?string $recordTitleAttribute = 'username';

View File

@ -67,6 +67,12 @@ class EditUser extends EditRecord
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make(),
$this->getSaveFormAction()->formId('form'),
]; ];
} }
protected function getFormActions(): array
{
return [];
}
} }

View File

@ -2,21 +2,12 @@
namespace App\Http\Controllers\Admin\Servers; namespace App\Http\Controllers\Admin\Servers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Allocation;
use App\Models\Node;
use Carbon\CarbonImmutable;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Http\Request;
use App\Models\Server;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Token\Plain;
use Prologue\Alerts\AlertsMessageBag;
use App\Models\ServerTransfer;
use Illuminate\Database\ConnectionInterface;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Nodes\NodeJWTService; use App\Models\Server;
use App\Services\Servers\TransferServerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Prologue\Alerts\AlertsMessageBag;
class ServerTransferController extends Controller class ServerTransferController extends Controller
{ {
@ -25,30 +16,10 @@ class ServerTransferController extends Controller
*/ */
public function __construct( public function __construct(
private AlertsMessageBag $alert, private AlertsMessageBag $alert,
private ConnectionInterface $connection, private TransferServerService $transferServerService,
private NodeJWTService $nodeJWTService,
) { ) {
} }
private function notify(Server $server, Plain $token): void
{
try {
Http::daemon($server->node)->post('/api/transfer', [
'json' => [
'server_id' => $server->uuid,
'url' => $server->node->getConnectionAddress() . "/api/servers/$server->uuid/archive",
'token' => 'Bearer ' . $token->toString(),
'server' => [
'uuid' => $server->uuid,
'start_on_completion' => false,
],
],
])->toPsrResponse();
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/** /**
* Starts a transfer of a server to a new node. * Starts a transfer of a server to a new node.
* *
@ -62,85 +33,12 @@ class ServerTransferController extends Controller
'allocation_additional' => 'nullable', 'allocation_additional' => 'nullable',
]); ]);
$node_id = $validatedData['node_id']; if ($this->transferServerService->handle($server, $validatedData)) {
$allocation_id = intval($validatedData['allocation_id']); $this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
$additional_allocations = array_map('intval', $validatedData['allocation_additional'] ?? []); } else {
// Check if the node is viable for the transfer.
$node = Node::query()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.id', $node_id)
->first();
if (!$node->isViable($server->memory, $server->disk)) {
$this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash(); $this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash();
return redirect()->route('admin.servers.view.manage', $server->id);
} }
$server->validateTransferState();
$this->connection->transaction(function () use ($server, $node_id, $allocation_id, $additional_allocations) {
// Create a new ServerTransfer entry.
$transfer = new ServerTransfer();
$transfer->server_id = $server->id;
$transfer->old_node = $server->node_id;
$transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')->all();
$transfer->new_additional_allocations = $additional_allocations;
$transfer->save();
// Add the allocations to the server, so they cannot be automatically assigned while the transfer is in progress.
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
// Generate a token for the destination node that the source node can use to authenticate with.
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
// Notify the source node of the pending outgoing transfer.
$this->notify($server, $token);
return $transfer;
});
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
return redirect()->route('admin.servers.view.manage', $server->id); return redirect()->route('admin.servers.view.manage', $server->id);
} }
/**
* Assigns the specified allocations to the specified server.
*/
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
{
$allocations = $additional_allocations;
$allocations[] = $allocation_id;
$node = Node::query()->findOrFail($node_id);
$unassigned = $node->allocations()
->whereNull('server_id')
->pluck('id')
->toArray();
$updateIds = [];
foreach ($allocations as $allocation) {
if (!in_array($allocation, $unassigned)) {
continue;
}
$updateIds[] = $allocation;
}
if (!empty($updateIds)) {
Allocation::query()->whereIn('id', $updateIds)->update(['server_id' => $server->id]);
}
}
} }

View File

@ -126,7 +126,7 @@ class ServersController extends Controller
$this->buildModificationService->handle($server, $request->only([ $this->buildModificationService->handle($server, $request->only([
'allocation_id', 'add_allocations', 'remove_allocations', 'allocation_id', 'add_allocations', 'remove_allocations',
'memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk',
'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled', 'database_limit', 'allocation_limit', 'backup_limit', 'oom_killer',
])); ]));
} catch (DataValidationException $exception) { } catch (DataValidationException $exception) {
throw new ValidationException($exception->getValidator()); throw new ValidationException($exception->getValidator());

View File

@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Api\Application\DatabaseHosts;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\DatabaseHost;
use Spatie\QueryBuilder\QueryBuilder;
use App\Services\Databases\Hosts\HostUpdateService;
use App\Services\Databases\Hosts\HostCreationService;
use App\Transformers\Api\Application\DatabaseHostTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\DatabaseHosts\GetDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\StoreDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\DeleteDatabaseHostRequest;
use App\Http\Requests\Api\Application\DatabaseHosts\UpdateDatabaseHostRequest;
class DatabaseHostController extends ApplicationApiController
{
/**
* DatabaseHostController constructor.
*/
public function __construct(
private HostCreationService $creationService,
private HostUpdateService $updateService
) {
parent::__construct();
}
/**
* Return all the database hosts currently registered on the Panel.
*/
public function index(GetDatabaseHostRequest $request): array
{
$databases = QueryBuilder::for(DatabaseHost::query())
->allowedFilters(['name', 'host'])
->allowedSorts(['id', 'name', 'host'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($databases)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Return a single database host.
*/
public function view(GetDatabaseHostRequest $request, DatabaseHost $databaseHost): array
{
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Store a new database host on the Panel and return an HTTP/201 response code with the
* new database host attached.
*
* @throws \Throwable
*/
public function store(StoreDatabaseHostRequest $request): JsonResponse
{
$databaseHost = $this->creationService->handle($request->validated());
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->addMeta([
'resource' => route('api.application.databases.view', [
'database_host' => $databaseHost->id,
]),
])
->respond(201);
}
/**
* Update a database host on the Panel and return the updated record to the user.
*
* @throws \Throwable
*/
public function update(UpdateDatabaseHostRequest $request, DatabaseHost $databaseHost): array
{
$databaseHost = $this->updateService->handle($databaseHost->id, $request->validated());
return $this->fractal->item($databaseHost)
->transformWith($this->getTransformer(DatabaseHostTransformer::class))
->toArray();
}
/**
* Delete a database host from the Panel.
*
* @throws \Exception
*/
public function delete(DeleteDatabaseHostRequest $request, DatabaseHost $databaseHost): Response
{
$databaseHost->delete();
return $this->returnNoContent();
}
}

View File

@ -2,12 +2,14 @@
namespace App\Http\Controllers\Api\Application\Servers; namespace App\Http\Controllers\Api\Application\Servers;
use Illuminate\Http\Response;
use App\Models\Server;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\ReinstallServerService;
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController; use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Servers\ServerWriteRequest;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Servers\ReinstallServerService;
use App\Services\Servers\SuspensionService;
use App\Services\Servers\TransferServerService;
use Illuminate\Http\Response;
class ServerManagementController extends ApplicationApiController class ServerManagementController extends ApplicationApiController
{ {
@ -16,7 +18,9 @@ class ServerManagementController extends ApplicationApiController
*/ */
public function __construct( public function __construct(
private ReinstallServerService $reinstallServerService, private ReinstallServerService $reinstallServerService,
private SuspensionService $suspensionService private SuspensionService $suspensionService,
private TransferServerService $transferServerService,
private DaemonServerRepository $daemonServerRepository,
) { ) {
parent::__construct(); parent::__construct();
} }
@ -57,4 +61,44 @@ class ServerManagementController extends ApplicationApiController
return $this->returnNoContent(); return $this->returnNoContent();
} }
/**
* Starts a transfer of a server to a new node.
*/
public function startTransfer(ServerWriteRequest $request, Server $server): Response
{
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
]);
if ($this->transferServerService->handle($server, $validatedData)) {
// Transfer started
$this->returnNoContent();
} else {
// Node was not viable
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
}
}
/**
* Cancels a transfer of a server to a new node.
*
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
*/
public function cancelTransfer(ServerWriteRequest $request, Server $server): Response
{
if (!$transfer = $server->transfer) {
// Server is not transferring
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
}
$transfer->successful = true;
$transfer->save();
$this->daemonServerRepository->setServer($server)->cancelTransfer();
return $this->returnNoContent();
}
} }

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::WRITE;
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::READ;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Models\DatabaseHost;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreDatabaseHostRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_DATABASE_HOSTS;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return $rules ?? DatabaseHost::getRules();
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Models\DatabaseHost;
class UpdateDatabaseHostRequest extends StoreDatabaseHostRequest
{
public function rules(array $rules = null): array
{
/** @var DatabaseHost $databaseHost */
$databaseHost = $this->route()->parameter('database_host');
return $rules ?? DatabaseHost::getRulesForUpdate($databaseHost->id);
}
}

View File

@ -32,7 +32,7 @@ class StoreServerRequest extends ApplicationApiRequest
'startup' => $rules['startup'], 'startup' => $rules['startup'],
'environment' => 'present|array', 'environment' => 'present|array',
'skip_scripts' => 'sometimes|boolean', 'skip_scripts' => 'sometimes|boolean',
'oom_disabled' => 'sometimes|boolean', 'oom_killer' => 'sometimes|boolean',
// Resource limitations // Resource limitations
'limits' => 'required|array', 'limits' => 'required|array',
@ -94,7 +94,7 @@ class StoreServerRequest extends ApplicationApiRequest
'database_limit' => array_get($data, 'feature_limits.databases'), 'database_limit' => array_get($data, 'feature_limits.databases'),
'allocation_limit' => array_get($data, 'feature_limits.allocations'), 'allocation_limit' => array_get($data, 'feature_limits.allocations'),
'backup_limit' => array_get($data, 'feature_limits.backups'), 'backup_limit' => array_get($data, 'feature_limits.backups'),
'oom_disabled' => array_get($data, 'oom_disabled'), 'oom_killer' => array_get($data, 'oom_killer'),
]; ];
} }

View File

@ -16,7 +16,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
return [ return [
'allocation' => $rules['allocation_id'], 'allocation' => $rules['allocation_id'],
'oom_disabled' => $rules['oom_disabled'], 'oom_killer' => $rules['oom_killer'],
'limits' => 'sometimes|array', 'limits' => 'sometimes|array',
'limits.memory' => $this->requiredToOptional('memory', $rules['memory'], true), 'limits.memory' => $this->requiredToOptional('memory', $rules['memory'], true),

View File

@ -19,9 +19,11 @@ class StoreScheduleRequest extends ViewScheduleRequest
return [ return [
'name' => $rules['name'], 'name' => $rules['name'],
'is_active' => array_merge(['filled'], $rules['is_active']), 'is_active' => array_merge(['filled'], $rules['is_active']),
'only_when_online' => $rules['only_when_online'],
'minute' => $rules['cron_minute'], 'minute' => $rules['cron_minute'],
'hour' => $rules['cron_hour'], 'hour' => $rules['cron_hour'],
'day_of_month' => $rules['cron_day_of_month'], 'day_of_month' => $rules['cron_day_of_month'],
'month' => $rules['cron_month'],
'day_of_week' => $rules['cron_day_of_week'], 'day_of_week' => $rules['cron_day_of_week'],
]; ];
} }

View File

@ -35,7 +35,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @property int $io * @property int $io
* @property int $cpu * @property int $cpu
* @property string|null $threads * @property string|null $threads
* @property bool $oom_disabled * @property bool $oom_killer
* @property int $allocation_id * @property int $allocation_id
* @property int $egg_id * @property int $egg_id
* @property string $startup * @property string $startup
@ -90,7 +90,7 @@ use App\Exceptions\Http\Server\ServerStateConflictException;
* @method static \Illuminate\Database\Eloquent\Builder|Server whereMemory($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereMemory($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereName($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereNodeId($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOomDisabled($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereOomKiller($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOwnerId($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereOwnerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereSkipScripts($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereSkipScripts($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereStartup($value) * @method static \Illuminate\Database\Eloquent\Builder|Server whereStartup($value)
@ -124,7 +124,7 @@ class Server extends Model
*/ */
protected $attributes = [ protected $attributes = [
'status' => ServerState::Installing, 'status' => ServerState::Installing,
'oom_disabled' => true, 'oom_killer' => false,
'installed_at' => null, 'installed_at' => null,
]; ];
@ -150,7 +150,7 @@ class Server extends Model
'io' => 'required|numeric|between:0,1000', 'io' => 'required|numeric|between:0,1000',
'cpu' => 'required|numeric|min:0', 'cpu' => 'required|numeric|min:0',
'threads' => 'nullable|regex:/^[0-9-,]+$/', 'threads' => 'nullable|regex:/^[0-9-,]+$/',
'oom_disabled' => 'sometimes|boolean', 'oom_killer' => 'sometimes|boolean',
'disk' => 'required|numeric|min:0', 'disk' => 'required|numeric|min:0',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id', 'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'egg_id' => 'required|exists:eggs,id', 'egg_id' => 'required|exists:eggs,id',
@ -174,7 +174,7 @@ class Server extends Model
'disk' => 'integer', 'disk' => 'integer',
'io' => 'integer', 'io' => 'integer',
'cpu' => 'integer', 'cpu' => 'integer',
'oom_disabled' => 'boolean', 'oom_killer' => 'boolean',
'allocation_id' => 'integer', 'allocation_id' => 'integer',
'egg_id' => 'integer', 'egg_id' => 'integer',
'database_limit' => 'integer', 'database_limit' => 'integer',

View File

@ -19,6 +19,7 @@ use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\AuthenticateSession; use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession; use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession; use Illuminate\View\Middleware\ShareErrorsFromSession;
use Saade\FilamentLaravelLog\FilamentLaravelLogPlugin;
class AdminPanelProvider extends PanelProvider class AdminPanelProvider extends PanelProvider
{ {
@ -35,8 +36,8 @@ class AdminPanelProvider extends PanelProvider
->default() ->default()
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->topNavigation(true)
->login() ->login()
->brandName('Pelican')
->homeUrl('/') ->homeUrl('/')
->favicon('/pelican.ico') ->favicon('/pelican.ico')
->profile(EditProfile::class, false) ->profile(EditProfile::class, false)
@ -74,6 +75,13 @@ class AdminPanelProvider extends PanelProvider
]) ])
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ])
->plugin(
FilamentLaravelLogPlugin::make()
->navigationLabel('Logs')
->navigationIcon('tabler-file-info')
->slug('logs')
->authorize(fn () => auth()->user()->root_admin)
);
} }
} }

View File

@ -141,6 +141,46 @@ class DaemonServerRepository extends DaemonRepository
} }
} }
/**
* Cancels a server transfer.
*
* @throws \App\Exceptions\Http\Connection\DaemonConnectionException
*/
public function cancelTransfer(): void
{
Assert::isInstanceOf($this->server, Server::class);
if ($transfer = $this->server->transfer) {
// Source node
$this->setNode($transfer->oldNode);
try {
$this->getHttpClient()->delete(sprintf(
'/api/servers/%s/transfer',
$this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
// Destination node
$this->setNode($transfer->newNode);
try {
$this->getHttpClient()->delete('/api/transfer', [
'json' => [
'server_id' => $this->server->uuid,
'server' => [
'uuid' => $this->server->uuid,
],
],
]);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}
/** /**
* Revokes a single user's JTI by using their ID. This is simply a helper function to * Revokes a single user's JTI by using their ID. This is simply a helper function to
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted * make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted

View File

@ -40,8 +40,12 @@ class BuildModificationService
throw_unless($existingAllocation, new DisplayException('The requested default allocation is not currently assigned to this server.')); throw_unless($existingAllocation, new DisplayException('The requested default allocation is not currently assigned to this server.'));
} }
if (!isset($data['oom_killer']) && isset($data['oom_disabled'])) {
$data['oom_killer'] = !$data['oom_disabled'];
}
// If any of these values are passed through in the data array go ahead and set them correctly on the server model. // If any of these values are passed through in the data array go ahead and set them correctly on the server model.
$merge = Arr::only($data, ['oom_disabled', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']); $merge = Arr::only($data, ['oom_killer', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']);
$server->forceFill(array_merge($merge, [ $server->forceFill(array_merge($merge, [
'database_limit' => Arr::get($data, 'database_limit', 0) ?? null, 'database_limit' => Arr::get($data, 'database_limit', 0) ?? null,

View File

@ -59,14 +59,12 @@ class ServerConfigurationStructureService
'cpu_limit' => $server->cpu, 'cpu_limit' => $server->cpu,
'threads' => $server->threads, 'threads' => $server->threads,
'disk_space' => $server->disk, 'disk_space' => $server->disk,
'oom_disabled' => $server->oom_disabled, // This field is deprecated — use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
], ],
'container' => [ 'container' => [
'image' => $server->image, 'image' => $server->image,
// This field is deprecated — use the value in the "build" block.
//
// TODO: remove this key in V2.
'oom_disabled' => $server->oom_disabled,
'requires_rebuild' => false, 'requires_rebuild' => false,
], ],
'allocations' => [ 'allocations' => [
@ -110,7 +108,7 @@ class ServerConfigurationStructureService
return $item->pluck('port'); return $item->pluck('port');
})->toArray(), })->toArray(),
'env' => $this->environment->handle($server), 'env' => $this->environment->handle($server),
'oom_disabled' => $server->oom_disabled, 'oom_disabled' => !$server->oom_killer,
'memory' => (int) $server->memory, 'memory' => (int) $server->memory,
'swap' => (int) $server->swap, 'swap' => (int) $server->swap,
'io' => (int) $server->io, 'io' => (int) $server->io,

View File

@ -47,6 +47,10 @@ class ServerCreationService
*/ */
public function handle(array $data, DeploymentObject $deployment = null): Server public function handle(array $data, DeploymentObject $deployment = null): Server
{ {
if (!isset($data['oom_killer']) && isset($data['oom_disabled'])) {
$data['oom_killer'] = !$data['oom_disabled'];
}
// If a deployment object has been passed we need to get the allocation // If a deployment object has been passed we need to get the allocation
// that the server should use, and assign the node from that allocation. // that the server should use, and assign the node from that allocation.
if ($deployment instanceof DeploymentObject) { if ($deployment instanceof DeploymentObject) {
@ -142,7 +146,7 @@ class ServerCreationService
'io' => Arr::get($data, 'io'), 'io' => Arr::get($data, 'io'),
'cpu' => Arr::get($data, 'cpu'), 'cpu' => Arr::get($data, 'cpu'),
'threads' => Arr::get($data, 'threads'), 'threads' => Arr::get($data, 'threads'),
'oom_disabled' => Arr::get($data, 'oom_disabled') ?? true, 'oom_killer' => Arr::get($data, 'oom_killer') ?? false,
'allocation_id' => Arr::get($data, 'allocation_id'), 'allocation_id' => Arr::get($data, 'allocation_id'),
'egg_id' => Arr::get($data, 'egg_id'), 'egg_id' => Arr::get($data, 'egg_id'),
'startup' => Arr::get($data, 'startup'), 'startup' => Arr::get($data, 'startup'),

View File

@ -0,0 +1,131 @@
<?php
namespace App\Services\Servers;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Allocation;
use App\Models\Node;
use App\Models\Server;
use App\Models\ServerTransfer;
use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\Http;
use Lcobucci\JWT\Token\Plain;
class TransferServerService
{
/**
* TransferService constructor.
*/
public function __construct(
private ConnectionInterface $connection,
private NodeJWTService $nodeJWTService,
) {
}
private function notify(Server $server, Plain $token): void
{
try {
Http::daemon($server->node)->post('/api/transfer', [
'json' => [
'server_id' => $server->uuid,
'url' => $server->node->getConnectionAddress() . "/api/servers/$server->uuid/archive",
'token' => 'Bearer ' . $token->toString(),
'server' => [
'uuid' => $server->uuid,
'start_on_completion' => false,
],
],
])->toPsrResponse();
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Starts a transfer of a server to a new node.
*
* @throws \Throwable
*/
public function handle(Server $server, array $data): bool
{
$node_id = $data['node_id'];
$allocation_id = intval($data['allocation_id']);
$additional_allocations = array_map('intval', $data['allocation_additional'] ?? []);
// Check if the node is viable for the transfer.
$node = Node::query()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemon_listen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.id', $node_id)
->first();
if (!$node->isViable($server->memory, $server->disk)) {
return false;
}
$server->validateTransferState();
$this->connection->transaction(function () use ($server, $node_id, $allocation_id, $additional_allocations) {
// Create a new ServerTransfer entry.
$transfer = new ServerTransfer();
$transfer->server_id = $server->id;
$transfer->old_node = $server->node_id;
$transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = $server->allocations->where('id', '!=', $server->allocation_id)->pluck('id')->all();
$transfer->new_additional_allocations = $additional_allocations;
$transfer->save();
// Add the allocations to the server, so they cannot be automatically assigned while the transfer is in progress.
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
// Generate a token for the destination node that the source node can use to authenticate with.
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
// Notify the source node of the pending outgoing transfer.
$this->notify($server, $token);
return $transfer;
});
return true;
}
/**
* Assigns the specified allocations to the specified server.
*/
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
{
$allocations = $additional_allocations;
$allocations[] = $allocation_id;
$node = Node::query()->findOrFail($node_id);
$unassigned = $node->allocations()
->whereNull('server_id')
->pluck('id')
->toArray();
$updateIds = [];
foreach ($allocations as $allocation) {
if (!in_array($allocation, $unassigned)) {
continue;
}
$updateIds[] = $allocation;
}
if (!empty($updateIds)) {
Allocation::query()->whereIn('id', $updateIds)->update(['server_id' => $server->id]);
}
}
}

View File

@ -2,8 +2,10 @@
namespace App\Transformers\Api\Application; namespace App\Transformers\Api\Application;
use App\Models\Node;
use App\Models\Database; use App\Models\Database;
use App\Models\DatabaseHost; use App\Models\DatabaseHost;
use League\Fractal\Resource\Item;
use League\Fractal\Resource\Collection; use League\Fractal\Resource\Collection;
use League\Fractal\Resource\NullResource; use League\Fractal\Resource\NullResource;
use App\Services\Acl\Api\AdminAcl; use App\Services\Acl\Api\AdminAcl;
@ -12,6 +14,7 @@ class DatabaseHostTransformer extends BaseTransformer
{ {
protected array $availableIncludes = [ protected array $availableIncludes = [
'databases', 'databases',
'node',
]; ];
/** /**
@ -54,4 +57,20 @@ class DatabaseHostTransformer extends BaseTransformer
return $this->collection($model->getRelation('databases'), $this->makeTransformer(ServerDatabaseTransformer::class), Database::RESOURCE_NAME); return $this->collection($model->getRelation('databases'), $this->makeTransformer(ServerDatabaseTransformer::class), Database::RESOURCE_NAME);
} }
/**
* Include the node associated with this host.
*
* @throws \App\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeNode(DatabaseHost $model): Item|NullResource
{
if (!$this->authorize(AdminAcl::RESOURCE_NODES)) {
return $this->null();
}
$model->loadMissing('node');
return $this->item($model->getRelation('node'), $this->makeTransformer(NodeTransformer::class), Node::RESOURCE_NAME);
}
} }

View File

@ -65,7 +65,9 @@ class ServerTransformer extends BaseTransformer
'io' => $server->io, 'io' => $server->io,
'cpu' => $server->cpu, 'cpu' => $server->cpu,
'threads' => $server->threads, 'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled, // This field is deprecated, please use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
], ],
'feature_limits' => [ 'feature_limits' => [
'databases' => $server->database_limit, 'databases' => $server->database_limit,

View File

@ -56,7 +56,9 @@ class ServerTransformer extends BaseClientTransformer
'io' => $server->io, 'io' => $server->io,
'cpu' => $server->cpu, 'cpu' => $server->cpu,
'threads' => $server->threads, 'threads' => $server->threads,
'oom_disabled' => $server->oom_disabled, // This field is deprecated, please use "oom_killer".
'oom_disabled' => !$server->oom_killer,
'oom_killer' => $server->oom_killer,
], ],
'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)), 'invocation' => $service->handle($server, !$user->can(Permission::ACTION_STARTUP_READ, $server)),
'docker_image' => $server->image, 'docker_image' => $server->image,

View File

@ -32,6 +32,7 @@
"prologue/alerts": "^1.2", "prologue/alerts": "^1.2",
"ryangjchandler/blade-tabler-icons": "^2.3", "ryangjchandler/blade-tabler-icons": "^2.3",
"s1lentium/iptools": "~1.2.0", "s1lentium/iptools": "~1.2.0",
"saade/filament-laravel-log": "^3.2",
"spatie/laravel-fractal": "^6.1", "spatie/laravel-fractal": "^6.1",
"spatie/laravel-query-builder": "^5.8", "spatie/laravel-query-builder": "^5.8",
"symfony/mailgun-mailer": "^7.0", "symfony/mailgun-mailer": "^7.0",

761
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Facade;
return [ return [
'name' => 'Pelican', 'name' => env('APP_NAME', 'Pelican'),
'version' => 'canary', 'version' => 'canary',

View File

@ -46,6 +46,8 @@ return [
], ],
'redis' => [ 'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'default' => [ 'default' => [
'scheme' => env('REDIS_SCHEME', 'tcp'), 'scheme' => env('REDIS_SCHEME', 'tcp'),
'path' => env('REDIS_PATH', '/run/redis/redis.sock'), 'path' => env('REDIS_PATH', '/run/redis/redis.sock'),

View File

@ -35,7 +35,7 @@ class ServerFactory extends Factory
'io' => 500, 'io' => 500,
'cpu' => 0, 'cpu' => 0,
'threads' => null, 'threads' => null,
'oom_disabled' => 0, 'oom_killer' => false,
'startup' => '/bin/bash echo "hello world"', 'startup' => '/bin/bash echo "hello world"',
'image' => 'foo/bar:latest', 'image' => 'foo/bar:latest',
'allocation_limit' => null, 'allocation_limit' => null,

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->tinyInteger('oom_killer')->unsigned()->default(0)->after('oom_disabled');
});
DB::table('servers')->select(['id', 'oom_disabled'])->cursor()->each(function ($server) {
DB::table('servers')->where('id', $server->id)->update(['oom_killer' => !$server->oom_disabled]);
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('oom_disabled');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('servers', function (Blueprint $table) {
$table->tinyInteger('oom_disabled')->unsigned()->default(0)->after('oom_killer');
});
DB::table('servers')->select(['id', 'oom_killer'])->cursor()->each(function ($server) {
DB::table('servers')->where('id', $server->id)->update(['oom_disabled' => !$server->oom_killer]);
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('oom_killer');
});
}
};

View File

@ -495,7 +495,7 @@ CREATE TABLE `servers` (
`io` int unsigned NOT NULL, `io` int unsigned NOT NULL,
`cpu` int unsigned NOT NULL, `cpu` int unsigned NOT NULL,
`threads` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `threads` varchar(191) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`oom_disabled` tinyint unsigned NOT NULL DEFAULT '0', `oom_killer` tinyint unsigned NOT NULL DEFAULT '0',
`allocation_id` int unsigned NOT NULL, `allocation_id` int unsigned NOT NULL,
`egg_id` int unsigned NOT NULL, `egg_id` int unsigned NOT NULL,
`startup` text COLLATE utf8mb4_unicode_ci NOT NULL, `startup` text COLLATE utf8mb4_unicode_ci NOT NULL,
@ -844,3 +844,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (197,'2024_03_12_15
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (198,'2024_03_14_055537_remove_locations_table',2); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (198,'2024_03_14_055537_remove_locations_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (201,'2024_04_20_214441_add_egg_var_sort',3); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (201,'2024_04_20_214441_add_egg_var_sort',3);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (203,'2024_04_14_002250_update_column_names',4); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (203,'2024_04_14_002250_update_column_names',4);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (204,'2024_05_08_094823_rename_oom_disabled_column_to_oom_killer',1);

View File

@ -17,7 +17,6 @@
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_DRIVER" value="array"/>
</php> </php>
<source> <source>
<include> <include>

View File

@ -0,0 +1 @@
.mt-4{margin-top:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}html.dark .ace-filament .ace_scrollbar::-webkit-scrollbar{width:.75rem}html.dark .ace-filament .ace_scrollbar::-webkit-scrollbar-track{--tw-bg-opacity:1;background-color:rgba(var(--gray-900),var(--tw-bg-opacity))}html.dark .ace-filament .ace_scrollbar::-webkit-scrollbar-thumb{border-radius:4px;--tw-bg-opacity:1;background-color:rgba(var(--gray-800),var(--tw-bg-opacity))}html.dark .ace-filament .ace_gutter{--tw-bg-opacity:1;background-color:rgba(var(--gray-900),var(--tw-bg-opacity));--tw-text-opacity:1;color:rgba(var(--gray-400),var(--tw-text-opacity))}html.dark .ace-filament .ace_print-margin{display:none}html.dark .ace-filament{--tw-bg-opacity:1;background-color:rgba(var(--gray-900),var(--tw-bg-opacity));--tw-text-opacity:1;color:rgba(var(--gray-200),var(--tw-text-opacity));--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-color:#fff3}html.dark .ace-filament,html:not(.dark) .ace-filament{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}html:not(.dark) .ace-filament{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);--tw-ring-color:rgba(var(--gray-950),0.1)}html.dark .ace-filament .ace_cursor{--tw-text-opacity:1;color:rgba(var(--gray-200),var(--tw-text-opacity))}html.dark .ace-filament .ace_marker-layer .ace_selection{background-color:rgba(var(--primary-500),.75)}html.dark .ace-filament.ace_multiselect .ace_selection.ace_start{box-shadow:0 0 3px 0 #002240}html.dark .ace-filament .ace_marker-layer .ace_step{background:#7f6f13}html.dark .ace-filament .ace_marker-layer .ace_bracket{margin:-1px 0 0 -1px;border:1px solid #ffffff26}html.dark .ace-filament .ace_marker-layer .ace_active-line{background:#18b69b1a}html.dark .ace-filament .ace_gutter-active-line{background-color:#00000059}html.dark .ace-filament .ace_marker-layer .ace_selected-word{border:1px solid #b36539bf}html.dark .ace-filament .ace_invisible{color:#ffffff26}html.dark .ace-filament .ace_keyword,html.dark .ace-filament .ace_meta{color:#ff9d00}html.dark .ace-filament .ace_constant,html.dark .ace-filament .ace_constant.ace_character,html.dark .ace-filament .ace_constant.ace_character.ace_escape,html.dark .ace-filament .ace_constant.ace_other{color:#ff628c}html.dark .ace-filament .ace_invalid{color:#f8f8f8;background-color:#800f00}html.dark .ace-filament .ace_support{color:#80ffbb}html.dark .ace-filament .ace_support.ace_constant{color:#eb939a}html.dark .ace-filament .ace_fold{background-color:#ff9d00;border-color:#f9fafb}html.dark .ace-filament .ace_support.ace_function{color:#ffb054}html.dark .ace-filament .ace_storage{color:#ffee80}html.dark .ace-filament .ace_entity{color:#fd0}html.dark .ace-filament .ace_string{color:#7cd827}html.dark .ace-filament .ace_string.ace_regexp{color:#80ffc2}html.dark .ace-filament .ace_comment{font-style:italic;color:#6b7280}html.dark .ace-filament .ace_heading,html.dark .ace-filament .ace_markup.ace_heading{color:#c8e4fd;background-color:#001221}html.dark .ace-filament .ace_list,html.dark .ace-filament .ace_markup.ace_list{background-color:#130d26}html.dark .ace-filament .ace_variable{color:#ccc}html.dark .ace-filament .ace_variable.ace_language{color:#ff80e1}html.dark .ace-filament .ace_meta.ace_tag{color:#9effff}html.dark .ace-filament .ace_indent-guide{background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHCLSvkPAAP3AgSDTRd4AAAAAElFTkSuQmCC) 100% repeat-y}

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
![image](https://github.com/pelican-dev/panel/assets/1296882/6c545ae3-24b9-4d30-af21-d08d2e53b673) <img width="20%" src="https://raw.githubusercontent.com/pelican-dev/panel/main/public/pelican.svg" alt="logo">
# Pelican Panel # Pelican Panel

View File

@ -203,8 +203,8 @@
</div> </div>
<div class="form-group col-xs-12"> <div class="form-group col-xs-12">
<div class="checkbox checkbox-primary no-margin-bottom"> <div class="checkbox checkbox-primary no-margin-bottom">
<input type="checkbox" id="pOomDisabled" name="oom_disabled" value="0" {{ \App\Helpers\Utilities::checked('oom_disabled', 0) }} /> <input type="checkbox" id="pOomKiller" name="oom_killer" value="0" {{ \App\Helpers\Utilities::checked('oom_killer', 0) }} />
<label for="pOomDisabled" class="strong">Enable OOM Killer</label> <label for="pOomKiller" class="strong">Enable OOM Killer</label>
</div> </div>
<p class="small text-muted no-margin">Terminates the server if it breaches the memory limits. Enabling OOM killer may cause server processes to exit unexpectedly.</p> <p class="small text-muted no-margin">Terminates the server if it breaches the memory limits. Enabling OOM killer may cause server processes to exit unexpectedly.</p>

View File

@ -74,11 +74,11 @@
<label for="cpu" class="control-label">OOM Killer</label> <label for="cpu" class="control-label">OOM Killer</label>
<div> <div>
<div class="radio radio-danger radio-inline"> <div class="radio radio-danger radio-inline">
<input type="radio" id="pOomKillerEnabled" value="0" name="oom_disabled" @if(!$server->oom_disabled)checked @endif> <input type="radio" id="pOomKillerEnabled" value="1" name="oom_killer" @if(!$server->oom_killer)checked @endif>
<label for="pOomKillerEnabled">Enabled</label> <label for="pOomKillerEnabled">Enabled</label>
</div> </div>
<div class="radio radio-success radio-inline"> <div class="radio radio-success radio-inline">
<input type="radio" id="pOomKillerDisabled" value="1" name="oom_disabled" @if($server->oom_disabled)checked @endif> <input type="radio" id="pOomKillerDisabled" value="0" name="oom_killer" @if($server->oom_killer)checked @endif>
<label for="pOomKillerDisabled">Disabled</label> <label for="pOomKillerDisabled">Disabled</label>
</div> </div>
<p class="text-muted small"> <p class="text-muted small">

View File

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="{{ config('scramble.theme', 'dark') }}"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

View File

@ -69,6 +69,8 @@ Route::prefix('/servers')->group(function () {
Route::post('/{server:id}/suspend', [Application\Servers\ServerManagementController::class, 'suspend'])->name('api.application.servers.suspend'); Route::post('/{server:id}/suspend', [Application\Servers\ServerManagementController::class, 'suspend'])->name('api.application.servers.suspend');
Route::post('/{server:id}/unsuspend', [Application\Servers\ServerManagementController::class, 'unsuspend'])->name('api.application.servers.unsuspend'); Route::post('/{server:id}/unsuspend', [Application\Servers\ServerManagementController::class, 'unsuspend'])->name('api.application.servers.unsuspend');
Route::post('/{server:id}/reinstall', [Application\Servers\ServerManagementController::class, 'reinstall'])->name('api.application.servers.reinstall'); Route::post('/{server:id}/reinstall', [Application\Servers\ServerManagementController::class, 'reinstall'])->name('api.application.servers.reinstall');
Route::post('/{server:id}/transfer', [Application\Servers\ServerManagementController::class, 'startTransfer'])->name('api.application.servers.transfer');
Route::post('/{server:id}/transfer/cancel', [Application\Servers\ServerManagementController::class, 'cancelTransfer'])->name('api.application.servers.transfer.cancel');
Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']); Route::delete('/{server:id}', [Application\Servers\ServerController::class, 'delete']);
Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']); Route::delete('/{server:id}/{force?}', [Application\Servers\ServerController::class, 'delete']);
@ -97,3 +99,22 @@ Route::prefix('/eggs')->group(function () {
Route::get('/', [Application\Eggs\EggController::class, 'index'])->name('api.application.eggs.eggs'); Route::get('/', [Application\Eggs\EggController::class, 'index'])->name('api.application.eggs.eggs');
Route::get('/{egg:id}', [Application\Eggs\EggController::class, 'view'])->name('api.application.eggs.eggs.view'); Route::get('/{egg:id}', [Application\Eggs\EggController::class, 'view'])->name('api.application.eggs.eggs.view');
}); });
/*
|--------------------------------------------------------------------------
| Database Host Controller Routes
|--------------------------------------------------------------------------
|
| Endpoint: /api/application/database-hosts
|
*/
Route::group(['prefix' => '/database-hosts'], function () {
Route::get('/', [Application\DatabaseHosts\DatabaseHostController::class, 'index'])->name('api.application.databasehosts');
Route::get('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'view'])->name('api.application.databasehosts.view');
Route::post('/', [Application\DatabaseHosts\DatabaseHostController::class, 'store']);
Route::patch('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'update']);
Route::delete('/{database_host:id}', [Application\DatabaseHosts\DatabaseHostController::class, 'delete']);
});

View File

@ -57,7 +57,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
$response = $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/schedules", []); $response = $this->actingAs($user)->postJson("/api/client/servers/$server->uuid/schedules", []);
$response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY); $response->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
foreach (['name', 'minute', 'hour', 'day_of_month', 'day_of_week'] as $i => $field) { foreach (['name', 'minute', 'hour', 'day_of_month', 'month', 'day_of_week'] as $i => $field) {
$response->assertJsonPath("errors.$i.code", 'ValidationException'); $response->assertJsonPath("errors.$i.code", 'ValidationException');
$response->assertJsonPath("errors.$i.meta.rule", 'required'); $response->assertJsonPath("errors.$i.meta.rule", 'required');
$response->assertJsonPath("errors.$i.meta.source_field", $field); $response->assertJsonPath("errors.$i.meta.source_field", $field);
@ -67,6 +67,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
->postJson("/api/client/servers/$server->uuid/schedules", [ ->postJson("/api/client/servers/$server->uuid/schedules", [
'name' => 'Testing', 'name' => 'Testing',
'is_active' => 'no', 'is_active' => 'no',
'only_when_online' => 'false',
'minute' => '*', 'minute' => '*',
'hour' => '*', 'hour' => '*',
'day_of_month' => '*', 'day_of_month' => '*',

View File

@ -114,7 +114,7 @@ class BuildModificationServiceTest extends IntegrationTestCase
$this->daemonServerRepository->expects('sync')->withNoArgs()->andReturnUndefined(); $this->daemonServerRepository->expects('sync')->withNoArgs()->andReturnUndefined();
$response = $this->getService()->handle($server, [ $response = $this->getService()->handle($server, [
'oom_disabled' => false, 'oom_killer' => false,
'memory' => 256, 'memory' => 256,
'swap' => 128, 'swap' => 128,
'io' => 600, 'io' => 600,
@ -126,7 +126,7 @@ class BuildModificationServiceTest extends IntegrationTestCase
'allocation_limit' => 20, 'allocation_limit' => 20,
]); ]);
$this->assertFalse($response->oom_disabled); $this->assertFalse($response->oom_killer);
$this->assertSame(256, $response->memory); $this->assertSame(256, $response->memory);
$this->assertSame(128, $response->swap); $this->assertSame(128, $response->swap);
$this->assertSame(600, $response->io); $this->assertSame(600, $response->io);

View File

@ -138,7 +138,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
$this->assertSame($allocations[4]->id, $response->allocations[1]->id); $this->assertSame($allocations[4]->id, $response->allocations[1]->id);
$this->assertFalse($response->isSuspended()); $this->assertFalse($response->isSuspended());
$this->assertTrue($response->oom_disabled); $this->assertFalse($response->oom_killer);
$this->assertSame(0, $response->database_limit); $this->assertSame(0, $response->database_limit);
$this->assertSame(0, $response->allocation_limit); $this->assertSame(0, $response->allocation_limit);
$this->assertSame(0, $response->backup_limit); $this->assertSame(0, $response->backup_limit);