diff --git a/app/Livewire/Project/Shared/ResourceLimits.php b/app/Livewire/Project/Shared/ResourceLimits.php index 0b3840289..e1e89c140 100644 --- a/app/Livewire/Project/Shared/ResourceLimits.php +++ b/app/Livewire/Project/Shared/ResourceLimits.php @@ -9,31 +9,33 @@ class ResourceLimits extends Component { use AuthorizesRequests; - public $resource; + // Default values for resource limits + private const DEFAULT_CPU_LIMIT = 0.0; + private const DEFAULT_CPU_SET = '0'; + private const DEFAULT_CPU_SHARES = 1024; + private const DEFAULT_MEMORY_SWAPPINESS = 60; + private const DEFAULT_MEMORY_LIMIT = '0'; + private const DEFAULT_MEMORY_SWAP = '0'; + private const DEFAULT_MEMORY_RESERVATION = '0'; - // Explicit properties - public ?string $limitsCpus = null; + public mixed $resource; + public ?float $limitsCpus = null; public ?string $limitsCpuset = null; - public ?int $limitsCpuShares = null; - - public string $limitsMemory; - - public string $limitsMemorySwap; - - public int $limitsMemorySwappiness; - - public string $limitsMemoryReservation; + public ?string $limitsMemory = null; + public ?string $limitsMemorySwap = null; + public ?int $limitsMemorySwappiness = null; + public ?string $limitsMemoryReservation = null; protected $rules = [ - 'limitsMemory' => 'required|string', - 'limitsMemorySwap' => 'required|string', - 'limitsMemorySwappiness' => 'required|integer|min:0|max:100', - 'limitsMemoryReservation' => 'required|string', - 'limitsCpus' => 'nullable', - 'limitsCpuset' => 'nullable', - 'limitsCpuShares' => 'nullable', + 'limitsMemory' => 'nullable|string', + 'limitsMemorySwap' => 'nullable|string', + 'limitsMemorySwappiness' => 'nullable|integer|min:0|max:100', + 'limitsMemoryReservation' => 'nullable|string', + 'limitsCpus' => 'nullable|numeric|min:0|max:1024', + 'limitsCpuset' => 'nullable|string', + 'limitsCpuShares' => 'nullable|integer|min:0|max:8192', ]; protected $validationAttributes = [ @@ -51,7 +53,7 @@ class ResourceLimits extends Component * * @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties. */ - private function syncData(bool $toModel = false): void + private function syncData(bool $toModel): void { if ($toModel) { // Sync TO model (before save) @@ -62,58 +64,87 @@ class ResourceLimits extends Component $this->resource->limits_memory_swap = $this->limitsMemorySwap; $this->resource->limits_memory_swappiness = $this->limitsMemorySwappiness; $this->resource->limits_memory_reservation = $this->limitsMemoryReservation; - } else { - // Sync FROM model (on load/refresh) - $this->limitsCpus = $this->resource->limits_cpus; - $this->limitsCpuset = $this->resource->limits_cpuset; - $this->limitsCpuShares = $this->resource->limits_cpu_shares; - $this->limitsMemory = $this->resource->limits_memory; - $this->limitsMemorySwap = $this->resource->limits_memory_swap; - $this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness; - $this->limitsMemoryReservation = $this->resource->limits_memory_reservation; + + return; + } + + // Sync FROM model (on load/refresh) + $this->limitsCpus = $this->resource->limits_cpus; + $this->limitsCpuset = $this->resource->limits_cpuset; + $this->limitsCpuShares = $this->resource->limits_cpu_shares; + $this->limitsMemory = $this->resource->limits_memory; + $this->limitsMemorySwap = $this->resource->limits_memory_swap; + $this->limitsMemorySwappiness = $this->resource->limits_memory_swappiness; + $this->limitsMemoryReservation = $this->resource->limits_memory_reservation; + + // Convert default values to null so UI shows placeholders instead of defaults + if ($this->limitsCpus === self::DEFAULT_CPU_LIMIT) { + $this->limitsCpus = null; + } + if ($this->limitsCpuset === self::DEFAULT_CPU_SET) { + $this->limitsCpuset = null; + } + if ($this->limitsCpuShares === self::DEFAULT_CPU_SHARES) { + $this->limitsCpuShares = null; + } + if ($this->limitsMemorySwappiness === self::DEFAULT_MEMORY_SWAPPINESS) { + $this->limitsMemorySwappiness = null; + } + if ($this->limitsMemory === self::DEFAULT_MEMORY_LIMIT) { + $this->limitsMemory = null; + } + if ($this->limitsMemorySwap === self::DEFAULT_MEMORY_SWAP) { + $this->limitsMemorySwap = null; + } + if ($this->limitsMemoryReservation === self::DEFAULT_MEMORY_RESERVATION) { + $this->limitsMemoryReservation = null; } } - public function mount() + public function mount(): void { - $this->syncData(false); + $this->syncData(toModel: false); } - public function submit() + public function submit(): void { try { $this->authorize('update', $this->resource); - // Apply default values to properties - if (! $this->limitsMemory) { - $this->limitsMemory = '0'; + // Apply defaults for empty fields + if (empty($this->limitsMemory)) { + $this->limitsMemory = self::DEFAULT_MEMORY_LIMIT; } - if (! $this->limitsMemorySwap) { - $this->limitsMemorySwap = '0'; + if (empty($this->limitsMemorySwap)) { + $this->limitsMemorySwap = self::DEFAULT_MEMORY_SWAP; } - if (is_null($this->limitsMemorySwappiness)) { - $this->limitsMemorySwappiness = 60; + if (empty($this->limitsMemoryReservation)) { + $this->limitsMemoryReservation = self::DEFAULT_MEMORY_RESERVATION; } - if (! $this->limitsMemoryReservation) { - $this->limitsMemoryReservation = '0'; + if ($this->limitsCpus === null) { + $this->limitsCpus = self::DEFAULT_CPU_LIMIT; } - if (! $this->limitsCpus) { - $this->limitsCpus = '0'; + if (empty($this->limitsCpuset)) { + $this->limitsCpuset = self::DEFAULT_CPU_SET; } - if ($this->limitsCpuset === '') { - $this->limitsCpuset = null; + if ($this->limitsCpuShares === null) { + $this->limitsCpuShares = self::DEFAULT_CPU_SHARES; } - if (is_null($this->limitsCpuShares)) { - $this->limitsCpuShares = 1024; + if ($this->limitsMemorySwappiness === null) { + $this->limitsMemorySwappiness = self::DEFAULT_MEMORY_SWAPPINESS; } $this->validate(); - $this->syncData(true); + $this->syncData(toModel: true); $this->resource->save(); + + // Reload from model to convert defaults back to null for placeholder display + $this->syncData(toModel: false); + $this->dispatch('success', 'Resource limits updated.'); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } } diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 5ed347f42..608684a8d 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -34,12 +34,13 @@ class Input extends Component public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public ?string $suffix = null, ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { $hasPermission = Gate::allows($this->canGate, $this->canResource); - if (! $hasPermission) { + if (!$hasPermission) { $this->disabled = true; } } @@ -60,7 +61,7 @@ class Input extends Component if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness $uniqueSuffix = new Cuid2; - $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; + $this->htmlId = $this->modelBinding . '-' . $uniqueSuffix; } else { $this->htmlId = (string) $this->id; } @@ -69,7 +70,7 @@ class Input extends Component $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; } if ($this->type === 'password') { - $this->defaultClass = $this->defaultClass.' pr-[2.8rem]'; + $this->defaultClass = $this->defaultClass . ' pr-[2.8rem]'; } // $this->label = Str::title($this->label); diff --git a/app/View/Components/Forms/InputWithSelect.php b/app/View/Components/Forms/InputWithSelect.php new file mode 100644 index 000000000..0cc03b65b --- /dev/null +++ b/app/View/Components/Forms/InputWithSelect.php @@ -0,0 +1,87 @@ +canGate && $this->canResource && $this->autoDisable) { + $hasPermission = Gate::allows($this->canGate, $this->canResource); + + if (!$hasPermission) { + $this->disabled = true; + } + } + } + + public function render(): View|Closure|string + { + // Store original ID for wire:model binding (property name) + $this->modelBinding = $this->id; + + if (is_null($this->id)) { + $this->id = new Cuid2; + // Don't create wire:model binding for auto-generated IDs + $this->modelBinding = 'null'; + } + // Generate unique HTML ID by adding random suffix + // This prevents duplicate IDs when multiple forms are on the same page + if ($this->modelBinding && $this->modelBinding !== 'null') { + // Use original ID with random suffix for uniqueness + $uniqueSuffix = new Cuid2; + $this->htmlId = $this->modelBinding . '-' . $uniqueSuffix; + } else { + $this->htmlId = (string) $this->id; + } + + if (is_null($this->name)) { + $this->name = $this->modelBinding !== 'null' ? $this->modelBinding : (string) $this->id; + } + + if ($this->modelBinding && $this->modelBinding !== 'null') { + $this->combinedBinding = $this->modelBinding; + } + + if (is_null($this->defaultOption) && !empty($this->options)) { + $this->defaultOption = array_key_first($this->options); + } + + return view('components.forms.input-with-select'); + } +} diff --git a/resources/views/components/forms/input-with-select.blade.php b/resources/views/components/forms/input-with-select.blade.php new file mode 100644 index 000000000..51784d4c2 --- /dev/null +++ b/resources/views/components/forms/input-with-select.blade.php @@ -0,0 +1,167 @@ +@php + $inputId = $htmlId !== 'null' ? $htmlId . '-input' : null; + $selectId = $htmlId !== 'null' ? $htmlId . '-select' : null; +@endphp + + + +
+ @if ($label) + + @endif + +
+ {{-- Hidden input for wire:dirty tracking (binds to combinedValue which has the full value with unit) --}} + @if ($modelBinding !== 'null') + + @endif + + {{-- Input --}} + + + {{-- Select --}} + +
+ + @if (!$label && $helper) + + @endif + @error($modelBinding) + + @enderror +
+ + diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index cf72dfbe9..5f830af6b 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -1,3 +1,8 @@ +@php + $hasSuffix = (isset($suffix) && $suffix instanceof \Illuminate\View\ComponentSlot && $suffix->isNotEmpty()) || ($suffix ?? null); + $inputClass = $hasSuffix ? $defaultClass . ' rounded-r-none border-r-0' : $defaultClass; +@endphp +
$isMultiline, 'w-full' => !$isMultiline, @@ -45,8 +50,11 @@
@else + @if ($hasSuffix) +
+ @endif merge(['class' => $defaultClass]) }} @required($required) @readonly($readonly) + {{ $attributes->merge(['class' => $inputClass]) }} @required($required) @readonly($readonly) @if ($modelBinding !== 'null') wire:model={{ $modelBinding }} wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]" @endif wire:loading.attr="disabled" type="{{ $type }}" @disabled($disabled) min="{{ $attributes->get('min') }}" @@ -55,6 +63,12 @@ @if ($htmlId !== 'null') id={{ $htmlId }} @endif name="{{ $name }}" placeholder="{{ $attributes->get('placeholder') }}" @if ($autofocus) x-ref="autofocusInput" @endif> + @if ($hasSuffix) + + {{ $suffix }} + +
+ @endif @endif @if (!$label && $helper) diff --git a/resources/views/livewire/project/shared/resource-limits.blade.php b/resources/views/livewire/project/shared/resource-limits.blade.php index 99ff249e9..2eddcf646 100644 --- a/resources/views/livewire/project/shared/resource-limits.blade.php +++ b/resources/views/livewire/project/shared/resource-limits.blade.php @@ -1,40 +1,107 @@
-
-
+ +

Resource Limits

Save
-
Limit your container resources by CPU & memory.
-

Limit CPUs

-
- - - -
-

Limit Memory

-
-
- - +

Limit your container resources by CPU & memory.

+
+

Limit CPUs

+
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
-
- - +
+
+

Limit Memory

+
+
+ + +
+
+ + +
diff --git a/tests/Unit/InputWithSelectComponentTest.php b/tests/Unit/InputWithSelectComponentTest.php new file mode 100644 index 000000000..2d7565cb0 --- /dev/null +++ b/tests/Unit/InputWithSelectComponentTest.php @@ -0,0 +1,77 @@ +required)->toBeFalse() + ->and($component->disabled)->toBeFalse() + ->and($component->readonly)->toBeFalse() + ->and($component->defaultClass)->toBe('input') + ->and($component->type)->toBe('text') + ->and($component->options)->toBe([]); +}); + +it('uses provided id', function () { + $component = new InputWithSelect(id: 'test-input-select'); + + expect($component->id)->toBe('test-input-select'); +}); + +it('accepts options array', function () { + $options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']; + $component = new InputWithSelect(options: $options); + + expect($component->options)->toBe($options); +}); + +it('sets default option to first option when not provided', function () { + $options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB']; + $component = new InputWithSelect(options: $options); + + // defaultOption is set in render(), so we test the logic directly + if (is_null($component->defaultOption) && !empty($component->options)) { + $component->defaultOption = array_key_first($component->options); + } + + expect($component->defaultOption)->toBe('b'); +}); + +it('uses provided default option', function () { + $options = ['b' => 'B', 'k' => 'KiB', 'm' => 'MiB']; + $component = new InputWithSelect(options: $options, defaultOption: 'm'); + + expect($component->defaultOption)->toBe('m'); +}); + +it('accepts min and max values', function () { + $component = new InputWithSelect(min: 0, max: 100); + + expect($component->min)->toBe(0.0) + ->and($component->max)->toBe(100.0); +}); + +it('accepts type parameter', function () { + $component = new InputWithSelect(type: 'number'); + + expect($component->type)->toBe('number'); +}); + +it('accepts authorization properties', function () { + $component = new InputWithSelect( + canGate: 'update', + canResource: 'resource', + autoDisable: false + ); + + expect($component->canGate)->toBe('update') + ->and($component->canResource)->toBe('resource') + ->and($component->autoDisable)->toBeFalse(); +}); + +it('can be manually disabled', function () { + $component = new InputWithSelect(disabled: true); + + expect($component->disabled)->toBeTrue(); +});