serverCreationService = $serverCreationService;
}
public function form(Form $form): Form
{
return $form
->schema([
Wizard::make([
Step::make('Information')
->label(trans('admin/server.tabs.information'))
->icon('tabler-info-circle')
->completedIcon('tabler-check')
->columns([
'default' => 1,
'sm' => 4,
'md' => 4,
])
->schema([
TextInput::make('name')
->prefixIcon('tabler-server')
->label(trans('admin/server.name'))
->suffixAction(Forms\Components\Actions\Action::make('random')
->icon('tabler-dice-' . random_int(1, 6))
->action(function (Set $set, Get $get) {
$egg = Egg::find($get('egg_id'));
$prefix = $egg ? str($egg->name)->lower()->kebab() . '-' : '';
$word = (new RandomWordService())->word();
$set('name', $prefix . $word);
}))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->required()
->maxLength(255),
TextInput::make('external_id')
->label(trans('admin/server.external_id'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('node_id')
->disabledOn('edit')
->prefixIcon('tabler-server-2')
->selectablePlaceholder(false)
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->live()
->relationship('node', 'name')
->searchable()
->preload()
->afterStateUpdated(function (Set $set, $state) {
$set('allocation_id', null);
$this->node = Node::find($state);
})
->required(),
Select::make('owner_id')
->preload()
->prefixIcon('tabler-user')
->selectablePlaceholder(false)
->default(auth()->user()->id)
->label(trans('admin/server.owner'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->relationship('user', 'username')
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->username ($user->email)")
->createOptionForm([
TextInput::make('username')
->label(trans('admin/user.username'))
->alphaNum()
->required()
->minLength(3)
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->hintIcon('tabler-question-mark')
->hintIconTooltip(trans('admin/user.password_help'))
->password(),
])
->createOptionUsing(function ($data, UserCreationService $service) {
$service->handle($data);
$this->refreshForm();
})
->required(),
Select::make('allocation_id')
->preload()
->live()
->prefixIcon('tabler-network')
->label(trans('admin/server.primary_allocation'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->disabled(fn (Get $get) => $get('node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
->afterStateUpdated(function (Set $set) {
$set('allocation_additional', null);
$set('allocation_additional.needstobeastringhere.extra_allocations', null);
})
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(function (Get $get) {
$node = Node::find($get('node_id'));
if ($node?->allocations) {
return trans('admin/server.select_allocation');
}
return trans('admin/server.new_allocation');
})
->relationship(
'allocation',
'ip',
fn (Builder $query, Get $get) => $query
->where('node_id', $get('node_id'))
->whereNull('server_id'),
)
->createOptionForm(function (Get $get) {
$getPage = $get;
return [
Select::make('allocation_ip')
->options(collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->ipv4()
->live()
->required(),
TextInput::make('allocation_alias')
->label(trans('admin/server.alias'))->inlineLabel()
->default(null)
->datalist([
$get('name'),
Egg::find($get('egg_id'))?->name,
])
->helperText(trans('admin/server.alias_helper')),
TagsInput::make('allocation_ports')
->label(trans('admin/server.port'))->inlineLabel()
->placeholder('27015, 27017-27019')
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts(Node::find($getPage('node_id')), $state, $get('allocation_ip')))
)
->splitKeys(['Tab', ' ', ','])
->required(),
];
})
->createOptionUsing(function (array $data, Get $get, AssignmentService $assignmentService): int {
return collect(
$assignmentService->handle(Node::find($get('node_id')), $data)
)->first();
})
->required(),
Repeater::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
])
->addActionLabel('Add Allocation')
->disabled(fn (Get $get) => $get('allocation_id') === null)
// ->addable() TODO disable when all allocations are taken
// ->addable() TODO disable until first additional allocation is selected
->simple(
Select::make('extra_allocations')
->live()
->preload()
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->prefixIcon('tabler-network')
->label('Additional Allocations')
->columnSpan(2)
->disabled(fn (Get $get) => $get('../../node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
->getOptionLabelFromRecordUsing(
fn (Allocation $allocation) => "$allocation->ip:$allocation->port" .
($allocation->ip_alias ? " ($allocation->ip_alias)" : '')
)
->placeholder(trans('admin/server.select_additional'))
->disableOptionsWhenSelectedInSiblingRepeaterItems()
->relationship(
'allocations',
'ip',
fn (Builder $query, Get $get, Select $component, $state) => $query
->where('node_id', $get('../../node_id'))
->whereNot('id', $get('../../allocation_id'))
->whereNull('server_id'),
),
),
Textarea::make('description')
->label(trans('admin/server.description'))
->rows(3)
->columnSpan([
'default' => 1,
'sm' => 4,
'md' => 4,
]),
]),
Step::make(trans('admin/server.tabs.egg_configuration'))
->icon('tabler-egg')
->completedIcon('tabler-check')
->columns([
'default' => 1,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->schema([
Select::make('egg_id')
->label(trans('admin/server.name'))
->prefixIcon('tabler-egg')
->relationship('egg', 'name')
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->searchable()
->preload()
->live()
->afterStateUpdated(function ($state, Set $set, Get $get, $old) {
$egg = Egg::query()->find($state);
$set('startup', $egg->startup ?? '');
$set('image', '');
$variables = $egg->variables ?? [];
$serverVariables = collect();
foreach ($variables as $variable) {
$serverVariables->add($variable->toArray());
}
$variables = [];
$set($path = 'server_variables', $serverVariables->sortBy(['sort'])->all());
for ($i = 0; $i < $serverVariables->count(); $i++) {
$set("$path.$i.variable_value", $serverVariables[$i]['default_value']);
$set("$path.$i.variable_id", $serverVariables[$i]['id']);
$variables[$serverVariables[$i]['env_variable']] = $serverVariables[$i]['default_value'];
}
$set('environment', $variables);
$previousEgg = Egg::query()->find($old);
if (!$get('name') || $previousEgg?->getKebabName() === $get('name')) {
$set('name', $egg->getKebabName());
}
})
->required(),
ToggleButtons::make('skip_scripts')
->label(trans('admin/server.install_script'))
->default(false)
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->options([
false => trans('admin/server.yes'),
true => trans('admin/server.skip'),
])
->colors([
false => 'primary',
true => 'danger',
])
->icons([
false => 'tabler-code',
true => 'tabler-code-off',
])
->inline()
->required(),
ToggleButtons::make('start_on_completion')
->label(trans('admin/server.start_after'))
->default(true)
->required()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->options([
true => trans('admin/server.yes'),
false => trans('admin/server.no'),
])
->colors([
true => 'primary',
false => 'danger',
])
->icons([
true => 'tabler-code',
false => 'tabler-code-off',
])
->inline(),
Textarea::make('startup')
->hintIcon('tabler-code')
->label(trans('admin/server.startup_cmd'))
->hidden(fn (Get $get) => $get('egg_id') === null)
->required()
->live()
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
1
);
})
->columnSpan([
'default' => 1,
'sm' => 4,
'md' => 4,
'lg' => 6,
]),
Hidden::make('environment')->default([]),
Section::make(trans('admin/server.variables'))
->icon('tabler-eggs')
->iconColor('primary')
->hidden(fn (Get $get) => $get('egg_id') === null)
->collapsible()
->columnSpanFull()
->schema([
Placeholder::make(trans('admin/server.select_egg'))
->hidden(fn (Get $get) => $get('egg_id')),
Placeholder::make(trans('admin/server.no_variables'))
->hidden(fn (Get $get) => !$get('egg_id') ||
Egg::query()->find($get('egg_id'))?->variables()?->count()
),
Repeater::make('server_variables')
->label('')
->relationship('serverVariables', fn (Builder $query) => $query->orderByPowerJoins('variable.sort'))
->saveRelationshipsBeforeChildrenUsing(null)
->saveRelationshipsUsing(null)
->grid(2)
->reorderable(false)
->addable(false)
->deletable(false)
->default([])
->hidden(fn ($state) => empty($state))
->schema(function () {
$text = TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->required(fn (Get $get) => in_array('required', $get('rules')))
->rules(
fn (Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $get('rules'),
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $get('name'))->toString();
$fail($message);
}
},
);
$select = Select::make('variable_value')
->hidden($this->shouldHideComponent(...))
->options($this->getSelectOptionsFromRules(...))
->selectablePlaceholder(false);
$components = [$text, $select];
foreach ($components as &$component) {
$component = $component
->live(onBlur: true)
->hintIcon('tabler-code')
->label(fn (Get $get) => $get('name'))
->hintIconTooltip(fn (Get $get) => implode('|', $get('rules')))
->prefix(fn (Get $get) => '{{' . $get('env_variable') . '}}')
->helperText(fn (Get $get) => empty($get('description')) ? '—' : $get('description'))
->afterStateUpdated(function (Set $set, Get $get, $state) {
$environment = $get($envPath = '../../environment');
$environment[$get('env_variable')] = $state;
$set($envPath, $environment);
});
}
return $components;
})
->columnSpan(2),
]),
]),
Step::make(trans('admin/server.tabs.environment_configuration'))
->icon('tabler-brand-docker')
->completedIcon('tabler-check')
->schema([
Fieldset::make(trans('admin/server.resource_limits'))
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
])
->schema([
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->dehydrated()
->label(trans('admin/server.cpu'))->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->live()
->options([
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/server.cpu_limit'))->inlineLabel()
->suffix('%')
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0)
->helperText(trans('admin/server.cpu_helper')),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->dehydrated()
->label(trans('admin/server.memory'))->inlineLabel()->inline()
->default(true)
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->live()
->options([
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label(trans('admin/server.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->hintIcon('tabler-question-mark')
->hintIconToolTip(trans('admin/server.memory_helper'))
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->dehydrated()
->label(trans('admin/server.disk'))->inlineLabel()->inline()
->default(true)
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->options([
true => trans('admin/server.unlimited'),
false => trans('admin/server.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label(trans('admin/server.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
->required()
->columnSpan(2)
->numeric()
->minValue(0),
]),
]),
Fieldset::make(trans('admin/server.advanced_limits'))
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
])
->schema([
Hidden::make('io')
->helperText('The IO performance relative to other running containers')
->label('Block IO Proportion')
->default(500),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('cpu_pinning')
->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline()
->default(false)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->live()
->options([
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
true => 'warning',
])
->columnSpan(2),
TagsInput::make('threads')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => !$get('cpu_pinning'))
->label(trans('admin/server.threads'))->inlineLabel()
->required(fn (Get $get) => $get('cpu_pinning'))
->columnSpan(2)
->separator()
->splitKeys([','])
->placeholder(trans('admin/server.pin_help')),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('swap_support')
->live()
->label(trans('admin/server.swap'))
->inlineLabel()
->inline()
->columnSpan(2)
->default('disabled')
->afterStateUpdated(function ($state, Set $set) {
$value = match ($state) {
'unlimited' => -1,
'disabled' => 0,
'limited' => 128,
default => throw new LogicException('Invalid state'),
};
$set('swap', $value);
})
->options([
'unlimited' => trans('admin/server.unlimited'),
'limited' => trans('admin/server.limited'),
'disabled' => trans('admin/server.disabled'),
])
->colors([
'unlimited' => 'primary',
'limited' => 'warning',
'disabled' => 'danger',
]),
TextInput::make('swap')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited' => true,
default => false,
})
->label(trans('admin/server.swap_limit'))
->default(0)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->minValue(-1)
->columnSpan(2)
->inlineLabel()
->required()
->integer(),
]),
Grid::make()
->columns(4)
->columnSpanFull()
->schema([
ToggleButtons::make('oom_killer')
->label(trans('admin/server.oom'))
->inlineLabel()->inline()
->default(false)
->columnSpan(2)
->options([
false => trans('admin/server.disabled'),
true => trans('admin/server.enabled'),
])
->colors([
false => 'success',
true => 'danger',
]),
]),
]),
Fieldset::make(trans('admin/server.feature_limits'))
->inlineLabel()
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('allocation_limit')
->label(trans('admin/server.allocations'))
->suffixIcon('tabler-network')
->required()
->numeric()
->minValue(0)
->default(0),
TextInput::make('database_limit')
->label(trans('admin/server.databases'))
->suffixIcon('tabler-database')
->required()
->numeric()
->minValue(0)
->default(0),
TextInput::make('backup_limit')
->label(trans('admin/server.backups'))
->suffixIcon('tabler-copy-check')
->required()
->numeric()
->minValue(0)
->default(0),
]),
Fieldset::make(trans('admin/server.docker_settings'))
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 4,
])
->columnSpan(6)
->schema([
Select::make('select_image')
->label(trans('admin/server.image_name'))
->live()
->afterStateUpdated(fn (Set $set, $state) => $set('image', $state))
->options(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
$currentImage = $get('image');
if (!$currentImage && $images) {
$defaultImage = collect($images)->first();
$set('image', $defaultImage);
$set('select_image', $defaultImage);
}
return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image'];
})
->selectablePlaceholder(false)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
TextInput::make('image')
->label(trans('admin/server.image'))
->required()
->afterStateUpdated(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
$images = $egg->docker_images ?? [];
if (in_array($state, $images)) {
$set('select_image', $state);
} else {
$set('select_image', 'ghcr.io/custom-image');
}
})
->placeholder(trans('admin/server.image_placeholder'))
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
KeyValue::make('docker_labels')
->label('Container Labels')
->keyLabel(trans('admin/server.title'))
->valueLabel(trans('admin/server.description'))
->columnSpanFull(),
CheckboxList::make('mounts')
->label('Mounts')
->live()
->relationship('mounts')
->options(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => $mount->name]) ?? [])
->descriptions(fn () => $this->node?->mounts->mapWithKeys(fn ($mount) => [$mount->id => "$mount->source -> $mount->target"]) ?? [])
->helperText(fn () => $this->node?->mounts->isNotEmpty() ? '' : 'No Mounts exist for this Node')
->columnSpanFull(),
]),
]),
])
->columnSpanFull()
->nextAction(fn (Action $action) => $action->label(trans('admin/server.next_step')))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
Create Server
BLADE))),
]);
}
public function refreshForm(): void
{
$this->fillForm();
}
protected function getFormActions(): array
{
return [];
}
protected function handleRecordCreation(array $data): Model
{
$data['allocation_additional'] = collect($data['allocation_additional'])->filter()->all();
try {
return $this->serverCreationService->handle($data);
} catch (Exception $exception) {
Notification::make()
->title(trans('admin/server.notifications.create_failed'))
->body($exception->getMessage())
->color('danger')
->danger()
->send();
throw new Halt();
}
}
private function shouldHideComponent(Get $get, Component $component): bool
{
$containsRuleIn = collect($get('rules'))->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
);
if ($component instanceof Select) {
return $containsRuleIn;
}
if ($component instanceof TextInput) {
return !$containsRuleIn;
}
throw new Exception('Component type not supported: ' . $component::class);
}
/**
* @return array
*/
private function getSelectOptionsFromRules(Get $get): array
{
$inRule = collect($get('rules'))->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();
}
/**
* @param string[] $portEntries
* @return array
*/
public static function retrieveValidPorts(Node $node, array $portEntries, string $ip): array
{
$portRangeLimit = AssignmentService::PORT_RANGE_LIMIT;
$portFloor = AssignmentService::PORT_FLOOR;
$portCeil = AssignmentService::PORT_CEIL;
$ports = collect();
$existingPorts = $node
->allocations()
->where('ip', $ip)
->pluck('port')
->all();
foreach ($portEntries as $portEntry) {
$start = $end = $portEntry;
if (str_contains($portEntry, '-')) {
[$start, $end] = explode('-', $portEntry);
}
if (!is_numeric($start) || !is_numeric($end)) {
Notification::make()
->title(trans('admin/server.notifications.invalid_port_range'))
->danger()
->body(trans('admin/server.notifications.invalid_port_range_body', ['port' => $portEntry]))
->send();
continue;
}
$start = (int) $start;
$end = (int) $end;
$range = $start <= $end ? range($start, $end) : range($end, $start);
if (count($range) > $portRangeLimit) {
Notification::make()
->title(trans('admin/server.notifications.too_many_ports'))
->danger()
->body(trans('admin/server.notifications.too_many_ports_body', ['limit' => $portRangeLimit]))
->send();
continue;
}
foreach ($range as $i) {
// Invalid port number
if ($i <= $portFloor || $i > $portCeil) {
Notification::make()
->title(trans('admin/server.notifications.invalid_port'))
->danger()
->body(trans('admin/server.notifications.invalid_port_body', ['i' => $i, 'portFloor' => $portFloor, 'portCeil' => $portCeil]))
->send();
continue;
}
// Already exists
if (in_array($i, $existingPorts)) {
Notification::make()
->title(trans('admin/server.notifications.already_exists'))
->danger()
->body(trans('admin/server.notifications.already_exists_body', ['i' => $i]))
->send();
continue;
}
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$ports = $sortedPorts;
}
return $ports->all();
}
}