This commit is contained in:
Dominic Schmid 2026-03-11 03:21:08 +08:00 committed by GitHub
commit 921e1d9919
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 529 additions and 85 deletions

View file

@ -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);
}
}
}

View file

@ -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);

View file

@ -0,0 +1,87 @@
<?php
namespace App\View\Components\Forms;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Gate;
use Illuminate\View\Component;
use Visus\Cuid2\Cuid2;
class InputWithSelect extends Component
{
public ?string $modelBinding = null;
public ?string $htmlId = null;
public ?string $combinedBinding = null;
public function __construct(
public ?string $id = null,
public ?string $name = null,
public ?string $type = 'text',
public ?string $value = null,
public ?string $label = null,
public array $options = [],
public ?string $defaultOption = null,
public bool $required = false,
public bool $disabled = false,
public bool $readonly = false,
public ?string $helper = null,
public ?string $placeholder = null,
public string $defaultClass = 'input',
public string $autocomplete = 'off',
public ?int $minlength = null,
public ?int $maxlength = null,
public ?float $min = null,
public ?float $max = null,
public bool $autofocus = false,
public ?string $canGate = null,
public mixed $canResource = null,
public bool $autoDisable = true,
) {
// Handle authorization-based disabling
if ($this->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');
}
}

View file

@ -0,0 +1,167 @@
@php
$inputId = $htmlId !== 'null' ? $htmlId . '-input' : null;
$selectId = $htmlId !== 'null' ? $htmlId . '-select' : null;
@endphp
<style>
/* Apply dirty styling to visible input when hidden input is dirty */
.input-with-select-container input[type="hidden"].dirty-tracker ~ input {
box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5 !important;
}
.dark .input-with-select-container input[type="hidden"].dirty-tracker ~ input {
box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424 !important;
}
</style>
<div class="w-full"
x-data="inputWithSelect({
defaultUnit: @js($defaultOption ?? ''),
min: @js($min),
max: @js($max),
validUnits: @js(array_keys($options)),
@if ($modelBinding !== 'null')
entangled: @entangle($combinedBinding),
@else
entangled: @js($value ?? '0'),
@endif
})"
x-ref="container">
@if ($label)
<label @if ($inputId) for="{{ $inputId }}" @endif class="flex gap-1 items-center mb-1 text-sm font-medium">
{{ $label }}
@if ($required)
<x-highlighted text="*" />
@endif
@if ($helper)
<x-helper :helper="$helper" />
@endif
</label>
@endif
<div class="flex input-with-select-container">
{{-- Hidden input for wire:dirty tracking (binds to combinedValue which has the full value with unit) --}}
@if ($modelBinding !== 'null')
<input type="hidden"
wire:model={{ $combinedBinding }}
wire:dirty.class="dirty-tracker"
/>
@endif
{{-- Input --}}
<input
type="{{ $type }}"
@if ($inputId) id="{{ $inputId }}" @endif
x-model="value"
@blur="commit()"
@disabled($disabled)
@readonly($readonly)
placeholder="{{ $placeholder }}"
autocomplete="{{ $autocomplete }}"
name="{{ $name }}-input"
@if ($min !== null) min="{{ $min }}" @endif
@if ($max !== null) max="{{ $max }}" @endif
minlength="{{ $minlength }}"
maxlength="{{ $maxlength }}"
class="{{ $defaultClass }} rounded-r-none flex-1 border-r-0"
@if ($autofocus) x-ref="autofocusInput" @endif
aria-label="{{ $label }}"
x-ref="input"
/>
{{-- Select --}}
<select
@if ($selectId) id="{{ $selectId }}" @endif
x-model="unit"
@change="commit()"
@disabled($disabled)
name="{{ $name }}-select"
class="select rounded-l-none w-auto min-w-[70px] border-l-0"
aria-label="{{ $label }} unit"
>
@foreach($options as $key => $display)
<option value="{{ $key }}">{{ $display }}</option>
@endforeach
</select>
</div>
@if (!$label && $helper)
<x-helper :helper="$helper" />
@endif
@error($modelBinding)
<label class="label">
<span class="text-red-500 label-text-alt">{{ $message }}</span>
</label>
@enderror
</div>
<script>
(function() {
let registered = false;
function registerInputWithSelect() {
// Prevent duplicate registration
if (registered) {
return;
}
Alpine.data('inputWithSelect', ({ defaultUnit, validUnits, entangled }) => ({
value: '',
unit: defaultUnit,
entangled: entangled,
init() {
this.fromCombined(this.entangled);
// Watch for external changes from Livewire
this.$watch('entangled', (newVal) => {
const current = this.combined;
if (newVal !== current) {
this.fromCombined(newVal);
}
});
},
get combined() {
return this.value ? this.value + this.unit : '0';
},
commit() {
this.entangled = this.combined;
},
fromCombined(raw) {
if (!raw || raw === '0' || raw === 'null' || raw === null) {
this.value = '';
this.unit = defaultUnit;
return;
}
const units = [...validUnits].sort((a, b) => b.length - a.length);
for (const u of units) {
if (raw.endsWith(u)) {
const val = raw.slice(0, -u.length);
if (val) {
this.value = val;
this.unit = u;
return;
}
}
}
this.value = raw;
this.unit = defaultUnit;
}
}));
registered = true;
}
// Alpine already initialized (SPA navigation) - register immediately
if (window.Alpine) {
registerInputWithSelect();
}
// Also listen for alpine:init (initial page load)
document.addEventListener('alpine:init', registerInputWithSelect);
})();
</script>

View file

@ -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
<div @class([
'flex-1' => $isMultiline,
'w-full' => !$isMultiline,
@ -45,8 +50,11 @@
</div>
@else
@if ($hasSuffix)
<div class="flex">
@endif
<input autocomplete="{{ $autocomplete }}" @if ($value) value="{{ $value }}" @endif
{{ $attributes->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)
<span class="flex items-center px-3 border border-l-0 rounded-r bg-coolgray-100 text-neutral-400 select-none">
{{ $suffix }}
</span>
</div>
@endif
@endif
@if (!$label && $helper)
<x-helper :helper="$helper" />

View file

@ -1,40 +1,107 @@
<div>
<form wire:submit='submit' class="flex flex-col">
<div class="flex items-center gap-2 ">
<form wire:submit='submit' class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2>Resource Limits</h2>
<x-forms.button canGate="update" :canResource="$resource" type='submit'>Save</x-forms.button>
</div>
<div class="">Limit your container resources by CPU & memory.</div>
<h3 class="pt-4">Limit CPUs</h3>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource" placeholder="1.5"
helper="0 means use all CPUs. Floating point number, like 0.002 or 1.5. More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="Number of CPUs" id="limitsCpus" />
<x-forms.input canGate="update" :canResource="$resource" placeholder="0-2"
helper="Empty means, use all CPU sets. 0-2 will use CPU 0, CPU 1 and CPU 2. More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="CPU sets to use" id="limitsCpuset" />
<x-forms.input canGate="update" :canResource="$resource" placeholder="1024"
helper="More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>here</a>."
label="CPU Weight" id="limitsCpuShares" />
</div>
<h3 class="pt-4">Limit Memory</h3>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_reservation'>here</a>."
label="Soft Memory Limit" id="limitsMemoryReservation" />
<x-forms.input canGate="update" :canResource="$resource"
helper="0-100.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_swappiness'>here</a>."
type="number" min="0" max="100" label="Swappiness"
id="limitsMemorySwappiness" />
<p>Limit your container resources by CPU & memory.</p>
<div class="flex flex-col gap-3 pt-4">
<h3>Limit CPUs</h3>
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row gap-4">
<x-forms.input canGate="update" :canResource="$resource" type="number" min="0" max="1024" step="0.1"
placeholder="0"
helper="Limit how much CPU the container can use. 0 means unlimited (use all available CPUs). Use decimal numbers like 1.5 for one and a half CPUs, or 0.5 for half a CPU.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-quota-constraint'>cpu-quota</a>."
label="CPU Limit" id="limitsCpus">
<x-slot:suffix>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 5m0 1a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1z" />
<path d="M9 9h6v6h-6z" />
<path d="M9 1v3" />
<path d="M15 1v3" />
<path d="M9 20v3" />
<path d="M15 20v3" />
<path d="M20 9h3" />
<path d="M20 14h3" />
<path d="M1 9h3" />
<path d="M1 14h3" />
<path d="M12 9v6" />
<path d="M9 12h6" />
</svg>
</x-slot:suffix>
</x-forms.input>
</div>
<div class="flex flex-col md:flex-row gap-4">
<x-forms.input canGate="update" :canResource="$resource" placeholder="0"
helper="Pin container to specific CPU threads. 0 means use all threads. Example: 0-1,4 results in using threads 0,1,4.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpuset-constraint'>cpuset</a>."
label="CPU sets to use" id="limitsCpuset">
<x-slot:suffix>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 4a3 3 0 0 1 6 0c0 1.657 -1.343 3 -3 3s-3 -1.343 -3 -3" />
<path d="M12 7v13" />
<path d="M9 20h6" />
</svg>
</x-slot:suffix>
</x-forms.input>
<x-forms.input canGate="update" :canResource="$resource" type="number" min="0" max="8192" step="64"
placeholder="1024"
helper="Relative CPU priority when containers compete for resources. Default: 1024 (normal). Examples: 512 = half priority, 2048 = double priority.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/engine/reference/run/#cpu-share-constraint'>cpu_shares</a>."
label="CPU Weight" id="limitsCpuShares">
<x-slot:suffix>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 7l0 10" />
<path d="M19 7l0 10" />
<path d="M7 5l10 0" />
<path d="M7 19l10 0" />
</svg>
</x-slot:suffix>
</x-forms.input>
</div>
</div>
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples: 69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_limit'>here</a>."
label="Maximum Memory Limit" id="limitsMemory" />
<x-forms.input canGate="update" :canResource="$resource"
helper="Examples:69b (byte) or 420k (kilobyte) or 1337m (megabyte) or 1g (gigabyte).<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#memswap_limit'>here</a>."
label="Maximum Swap Limit" id="limitsMemorySwap" />
</div>
<div class="flex flex-col gap-3 pt-4">
<h3>Limit Memory</h3>
<div class="flex flex-col gap-4">
<div class="flex flex-col md:flex-row gap-4">
<x-forms.input-with-select canGate="update" :canResource="$resource"
type="number"
min="0"
placeholder="0"
helper="Hard limit on container memory usage. The container will be killed if it exceeds this limit.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_limit'>mem_limit</a>."
label="Memory Limit" id="limitsMemory"
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
defaultOption="m" />
<x-forms.input-with-select canGate="update" :canResource="$resource"
type="number"
min="0"
placeholder="0"
helper="Guaranteed memory reservation for the container. Docker attempts to ensure this amount is always available.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_reservation'>mem_reservation</a>."
label="Memory Reservation" id="limitsMemoryReservation"
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
defaultOption="m" />
</div>
<div class="flex flex-col md:flex-row gap-4">
<x-forms.input-with-select canGate="update" :canResource="$resource"
type="number"
min="0"
placeholder="0"
helper="Total limit for memory plus swap space. Combined limit for both RAM and swap usage.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#memswap_limit'>memswap_limit</a>."
label="Maximum Swap Limit" id="limitsMemorySwap"
:options="['b' => 'B', 'k' => 'KiB', 'm' => 'MiB', 'g' => 'GiB']"
defaultOption="m" />
<x-forms.input canGate="update" :canResource="$resource"
placeholder="60"
helper="Control how aggressively the kernel swaps memory. 0 = swap only when necessary, 100 = swap aggressively. Default: 60.<br>More info <a class='underline dark:text-white' target='_blank' href='https://docs.docker.com/compose/compose-file/05-services/#mem_swappiness'>mem_swappiness</a>."
type="number" min="0" max="100" label="Swappiness"
id="limitsMemorySwappiness" suffix="%" />
</div>
</div>
</div>
</form>

View file

@ -0,0 +1,77 @@
<?php
use App\View\Components\Forms\InputWithSelect;
it('renders with default properties', function () {
$component = new InputWithSelect;
expect($component->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();
});