feat(admin): host reserve edit + maintenance drain; customer suspend/reactivate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
d3d686e575
commit
ef110b06db
|
|
@ -10,6 +10,21 @@ use Livewire\Component;
|
||||||
#[Layout('layouts.admin')]
|
#[Layout('layouts.admin')]
|
||||||
class Customers extends Component
|
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()
|
public function render()
|
||||||
{
|
{
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
|
|
@ -31,7 +46,8 @@ class Customers extends Component
|
||||||
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
|
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
|
||||||
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
|
||||||
'instance' => $instance->subdomain ?? '—',
|
'instance' => $instance->subdomain ?? '—',
|
||||||
'status' => $instance->status ?? $c->status ?? 'provisioning',
|
'suspended' => $c->status === 'suspended',
|
||||||
|
'status' => $c->status === 'suspended' ? 'suspended' : ($instance->status ?? $c->status ?? 'provisioning'),
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,28 @@ class HostDetail extends Component
|
||||||
$this->host->refresh();
|
$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
|
public function retry(): void
|
||||||
{
|
{
|
||||||
$run = $this->currentRun();
|
$run = $this->currentRun();
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ return [
|
||||||
'customers_sub' => 'Alle Kunden und ihre Pakete.',
|
'customers_sub' => 'Alle Kunden und ihre Pakete.',
|
||||||
'customers_empty' => 'Noch keine Kunden.',
|
'customers_empty' => 'Noch keine Kunden.',
|
||||||
'impersonate' => 'Verbinden',
|
'impersonate' => 'Verbinden',
|
||||||
|
'suspend' => 'Sperren',
|
||||||
|
'reactivate' => 'Entsperren',
|
||||||
|
'customer_suspended' => 'Kunde gesperrt.',
|
||||||
|
'customer_reactivated' => 'Kunde entsperrt.',
|
||||||
'by_plan' => 'Nach Paket',
|
'by_plan' => 'Nach Paket',
|
||||||
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
|
'instances_sub' => 'Alle bereitgestellten Cloud-Instanzen.',
|
||||||
'instances_label' => 'Instanzen',
|
'instances_label' => 'Instanzen',
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ return [
|
||||||
'never_seen' => 'Noch nie gesehen',
|
'never_seen' => 'Noch nie gesehen',
|
||||||
'committed' => ':gb GB belegt',
|
'committed' => ':gb GB belegt',
|
||||||
'reserve' => ':pct % Reserve',
|
'reserve' => ':pct % Reserve',
|
||||||
|
'reserve_label' => 'Reserve',
|
||||||
|
'reserve_save' => 'Speichern',
|
||||||
|
'reserve_saved' => 'Reserve aktualisiert.',
|
||||||
|
'drain' => 'In Wartung',
|
||||||
|
'activate' => 'Aktivieren',
|
||||||
],
|
],
|
||||||
|
|
||||||
'istatus' => [
|
'istatus' => [
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ return [
|
||||||
'customers_sub' => 'All customers and their plans.',
|
'customers_sub' => 'All customers and their plans.',
|
||||||
'customers_empty' => 'No customers yet.',
|
'customers_empty' => 'No customers yet.',
|
||||||
'impersonate' => 'Connect',
|
'impersonate' => 'Connect',
|
||||||
|
'suspend' => 'Suspend',
|
||||||
|
'reactivate' => 'Reactivate',
|
||||||
|
'customer_suspended' => 'Customer suspended.',
|
||||||
|
'customer_reactivated' => 'Customer reactivated.',
|
||||||
'by_plan' => 'By plan',
|
'by_plan' => 'By plan',
|
||||||
'instances_sub' => 'All provisioned cloud instances.',
|
'instances_sub' => 'All provisioned cloud instances.',
|
||||||
'instances_label' => 'instances',
|
'instances_label' => 'instances',
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ return [
|
||||||
'never_seen' => 'Never seen',
|
'never_seen' => 'Never seen',
|
||||||
'committed' => ':gb GB committed',
|
'committed' => ':gb GB committed',
|
||||||
'reserve' => ':pct % reserve',
|
'reserve' => ':pct % reserve',
|
||||||
|
'reserve_label' => 'Reserve',
|
||||||
|
'reserve_save' => 'Save',
|
||||||
|
'reserve_saved' => 'Reserve updated.',
|
||||||
|
'drain' => 'Maintenance',
|
||||||
|
'activate' => 'Activate',
|
||||||
],
|
],
|
||||||
|
|
||||||
'istatus' => [
|
'istatus' => [
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,22 @@
|
||||||
<td class="px-4 py-3 text-body">{{ $r['plan'] }}</td>
|
<td class="px-4 py-3 text-body">{{ $r['plan'] }}</td>
|
||||||
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
|
<td class="px-4 py-3 font-mono text-body">{{ $r['mrr'] }}</td>
|
||||||
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
|
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3">
|
||||||
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
@csrf
|
<button type="button" wire:click="toggleSuspend('{{ $r['uuid'] }}')"
|
||||||
<button type="submit"
|
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-semibold
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
|
{{ $r['suspended'] ? 'border-success-border text-success hover:bg-success-bg' : 'border-line text-muted hover:border-warning hover:text-warning' }}">
|
||||||
<x-ui.icon name="log-out" class="size-3.5" />
|
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
|
||||||
{{ __('admin.impersonate') }}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
|
||||||
|
<x-ui.icon name="log-out" class="size-3.5" />
|
||||||
|
{{ __('admin.impersonate') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
<x-ui.icon name="rotate-ccw" class="size-4" />{{ __('hosts.retry') }}
|
<x-ui.icon name="rotate-ccw" class="size-4" />{{ __('hosts.retry') }}
|
||||||
</x-ui.button>
|
</x-ui.button>
|
||||||
@endif
|
@endif
|
||||||
|
@if (in_array($host->status, ['active', 'disabled'], true))
|
||||||
|
<x-ui.button variant="secondary" size="sm" wire:click="toggleMaintenance" wire:loading.attr="disabled">
|
||||||
|
<x-ui.icon name="{{ $host->status === 'active' ? 'shield' : 'check' }}" class="size-4" />
|
||||||
|
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
|
||||||
|
</x-ui.button>
|
||||||
|
@endif
|
||||||
<x-ui.button variant="danger" size="sm"
|
<x-ui.button variant="danger" size="sm"
|
||||||
x-on:click="$dispatch('openModal', { component: 'admin.confirm-remove-host', arguments: { uuid: '{{ $host->uuid }}' } })">
|
x-on:click="$dispatch('openModal', { component: 'admin.confirm-remove-host', arguments: { uuid: '{{ $host->uuid }}' } })">
|
||||||
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove') }}
|
<x-ui.icon name="trash-2" class="size-4" />{{ __('hosts.remove') }}
|
||||||
|
|
@ -54,7 +60,13 @@
|
||||||
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
|
<div class="mt-2 h-2 overflow-hidden rounded-pill bg-surface-2">
|
||||||
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
|
<div class="h-full rounded-pill {{ $usedPct >= 80 ? 'bg-danger' : 'bg-accent' }}" style="width: {{ $usedPct }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1.5 text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve', ['pct' => $host->reserve_pct]) }}</p>
|
<div class="mt-2 flex items-center gap-2" x-data="{ r: {{ $host->reserve_pct }} }">
|
||||||
|
<span class="text-xs text-muted">{{ __('hosts.detail.committed', ['gb' => $host->committedGb()]) }} · {{ __('hosts.detail.reserve_label') }}</span>
|
||||||
|
<input type="number" min="0" max="90" x-model.number="r" aria-label="{{ __('hosts.detail.reserve_label') }}"
|
||||||
|
class="w-16 rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-ink" />
|
||||||
|
<span class="text-xs text-muted">%</span>
|
||||||
|
<button type="button" x-on:click="$wire.saveReserve(r)" class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">{{ __('hosts.detail.reserve_save') }}</button>
|
||||||
|
</div>
|
||||||
@else
|
@else
|
||||||
<p class="mt-2 text-sm text-muted">{{ __('hosts.unknown') }}</p>
|
<p class="mt-2 text-sm text-muted">{{ __('hosts.unknown') }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,18 @@ it('refuses to delete a datacenter that still has hosts', function () {
|
||||||
expect(Datacenter::query()->where('uuid', $dc->uuid)->exists())->toBeTrue();
|
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 () {
|
it('toggles a datacenter active flag', function () {
|
||||||
$dc = Datacenter::factory()->create(['active' => true]);
|
$dc = Datacenter::factory()->create(['active' => true]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,27 @@ it('filters the host list by search and datacenter', function () {
|
||||||
->assertDontSee('pve-fsn-9');
|
->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 () {
|
it('reports host heartbeat health from last_seen_at', function () {
|
||||||
$online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
|
$online = Host::factory()->create(['last_seen_at' => now()->subMinutes(2)]);
|
||||||
$stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);
|
$stale = Host::factory()->create(['last_seen_at' => now()->subMinutes(20)]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue