From ef110b06db09a09790c2757f09ad1479c876e676 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 14:30:21 +0200 Subject: [PATCH] feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate Co-Authored-By: Claude Opus 4.8 --- app/Livewire/Admin/Customers.php | 18 ++++++++++++++- app/Livewire/Admin/HostDetail.php | 22 ++++++++++++++++++ lang/de/admin.php | 4 ++++ lang/de/hosts.php | 5 ++++ lang/en/admin.php | 4 ++++ lang/en/hosts.php | 5 ++++ .../views/livewire/admin/customers.blade.php | 23 ++++++++++++------- .../livewire/admin/host-detail.blade.php | 14 ++++++++++- tests/Feature/Admin/DatacenterTest.php | 12 ++++++++++ tests/Feature/Admin/HostManagementTest.php | 21 +++++++++++++++++ 10 files changed, 118 insertions(+), 10 deletions(-) diff --git a/app/Livewire/Admin/Customers.php b/app/Livewire/Admin/Customers.php index 37fd7de..f52d1e1 100644 --- a/app/Livewire/Admin/Customers.php +++ b/app/Livewire/Admin/Customers.php @@ -10,6 +10,21 @@ use Livewire\Component; #[Layout('layouts.admin')] class Customers extends Component { + /** Suspend / reactivate a customer account (a guarded lifecycle action, not delete). */ + public function toggleSuspend(string $uuid): void + { + $customer = Customer::query()->where('uuid', $uuid)->first(); + if ($customer === null) { + return; + } + + $customer->update([ + 'status' => $customer->status === 'suspended' ? 'active' : 'suspended', + ]); + + $this->dispatch('notify', message: __('admin.customer_'.($customer->status === 'suspended' ? 'suspended' : 'reactivated'))); + } + public function render() { $locale = app()->getLocale(); @@ -31,7 +46,8 @@ class Customers extends Component 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), 'instance' => $instance->subdomain ?? '—', - 'status' => $instance->status ?? $c->status ?? 'provisioning', + 'suspended' => $c->status === 'suspended', + 'status' => $c->status === 'suspended' ? 'suspended' : ($instance->status ?? $c->status ?? 'provisioning'), ]; })->all(); diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 94defc2..5c27217 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -27,6 +27,28 @@ class HostDetail extends Component $this->host->refresh(); } + /** Adjust the capacity reserve (% of storage kept free for headroom). */ + public function saveReserve(int $reserve): void + { + $reserve = max(0, min(90, $reserve)); + $this->host->update(['reserve_pct' => $reserve]); + $this->dispatch('notify', message: __('hosts.detail.reserve_saved')); + } + + /** + * Drain / return a host: toggle between active and disabled. Disabled takes + * it out of placement (maintenance) without purging it — distinct from the + * destructive "remove host". Never touches a host mid-onboarding. + */ + public function toggleMaintenance(): void + { + if ($this->host->status === 'active') { + $this->host->update(['status' => 'disabled']); + } elseif ($this->host->status === 'disabled') { + $this->host->update(['status' => 'active']); + } + } + public function retry(): void { $run = $this->currentRun(); diff --git a/lang/de/admin.php b/lang/de/admin.php index 6306cb9..1baad03 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -55,6 +55,10 @@ return [ 'customers_sub' => 'Alle Kunden und ihre Pakete.', 'customers_empty' => 'Noch keine Kunden.', 'impersonate' => 'Verbinden', + 'suspend' => 'Sperren', + 'reactivate' => 'Entsperren', + 'customer_suspended' => 'Kunde gesperrt.', + 'customer_reactivated' => 'Kunde entsperrt.', 'by_plan' => 'Nach Paket', 'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.', 'instances_label' => 'Instanzen', diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 93dfb4c..1f92a04 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -76,6 +76,11 @@ return [ 'never_seen' => 'Noch nie gesehen', 'committed' => ':gb GB belegt', 'reserve' => ':pct % Reserve', + 'reserve_label' => 'Reserve', + 'reserve_save' => 'Speichern', + 'reserve_saved' => 'Reserve aktualisiert.', + 'drain' => 'In Wartung', + 'activate' => 'Aktivieren', ], 'istatus' => [ diff --git a/lang/en/admin.php b/lang/en/admin.php index 0559a41..4fa41b3 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -55,6 +55,10 @@ return [ 'customers_sub' => 'All customers and their plans.', 'customers_empty' => 'No customers yet.', 'impersonate' => 'Connect', + 'suspend' => 'Suspend', + 'reactivate' => 'Reactivate', + 'customer_suspended' => 'Customer suspended.', + 'customer_reactivated' => 'Customer reactivated.', 'by_plan' => 'By plan', 'instances_sub' => 'All provisioned cloud instances.', 'instances_label' => 'instances', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index e0def10..7992035 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -76,6 +76,11 @@ return [ 'never_seen' => 'Never seen', 'committed' => ':gb GB committed', 'reserve' => ':pct % reserve', + 'reserve_label' => 'Reserve', + 'reserve_save' => 'Save', + 'reserve_saved' => 'Reserve updated.', + 'drain' => 'Maintenance', + 'activate' => 'Activate', ], 'istatus' => [ diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 92fe163..bffc8e6 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -27,15 +27,22 @@ {{ $r['plan'] }} {{ $r['mrr'] }} {{ __('admin.status.'.$r['status']) }} - -
- @csrf - -
+
+ @csrf + +
+ @empty diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index bdfd1c9..63bf2bd 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -20,6 +20,12 @@ {{ __('hosts.retry') }} @endif + @if (in_array($host->status, ['active', 'disabled'], true)) + + + {{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }} + + @endif {{ __('hosts.remove') }} @@ -54,7 +60,13 @@
-

{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve', ['pct' => $host->reserve_pct]) }}

+
+ {{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve_label') }} + + % + +
@else

{{ __('hosts.unknown') }}

@endif diff --git a/tests/Feature/Admin/DatacenterTest.php b/tests/Feature/Admin/DatacenterTest.php index fff57ce..e937828 100644 --- a/tests/Feature/Admin/DatacenterTest.php +++ b/tests/Feature/Admin/DatacenterTest.php @@ -72,6 +72,18 @@ it('refuses to delete a datacenter that still has hosts', function () { expect(Datacenter::query()->where('uuid', $dc->uuid)->exists())->toBeTrue(); }); +it('suspends and reactivates a customer', function () { + $customer = \App\Models\Customer::factory()->create(['status' => 'active']); + + Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class) + ->call('toggleSuspend', $customer->uuid); + expect($customer->fresh()->status)->toBe('suspended'); + + Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class) + ->call('toggleSuspend', $customer->uuid); + expect($customer->fresh()->status)->toBe('active'); +}); + it('toggles a datacenter active flag', function () { $dc = Datacenter::factory()->create(['active' => true]); diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index 8881dc7..d48e06e 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -155,6 +155,27 @@ it('filters the host list by search and datacenter', function () { ->assertDontSee('pve-fsn-9'); }); +it('edits host reserve and toggles maintenance', function () { + $host = Host::factory()->active()->create(['reserve_pct' => 15]); + + Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host]) + ->call('saveReserve', 25) + ->call('toggleMaintenance'); + + $host->refresh(); + expect($host->reserve_pct)->toBe(25)->and($host->status)->toBe('disabled'); + + Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('toggleMaintenance'); + expect($host->fresh()->status)->toBe('active'); +}); + +it('clamps host reserve to a sane range', function () { + $host = Host::factory()->active()->create(['reserve_pct' => 15]); + + Livewire::actingAs(admin())->test(HostDetail::class, ['host' => $host])->call('saveReserve', 500); + expect($host->fresh()->reserve_pct)->toBe(90); +}); + it('reports host heartbeat health from last_seen_at', function () { $online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]); $stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);