feat(admin): datacenter edit+delete (guarded); MRR chart fills card; host-load as meters

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:22:15 +02:00
parent e3ab3cb501
commit 54d9a05235
9 changed files with 249 additions and 33 deletions

View File

@ -0,0 +1,47 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use LivewireUI\Modal\ModalComponent;
/**
* Confirmation for deleting a datacenter (R5). Guarded: a datacenter that still
* has hosts cannot be deleted it would orphan host placement so the modal
* blocks and explains instead. Otherwise it is safe to hard-delete an unused code.
*/
class ConfirmDeleteDatacenter extends ModalComponent
{
public string $uuid;
public string $name = '';
public int $hostCount = 0;
public function mount(string $uuid): void
{
$dc = Datacenter::query()->where('uuid', $uuid)->withCount('hosts')->firstOrFail();
$this->uuid = $uuid;
$this->name = $dc->name;
$this->hostCount = $dc->hosts_count;
}
public function delete()
{
$dc = Datacenter::query()->where('uuid', $this->uuid)->withCount('hosts')->first();
// Re-check under the current state — never delete a datacenter with hosts.
if ($dc === null || $dc->hosts_count > 0) {
return $this->redirectRoute('admin.datacenters', navigate: true);
}
$dc->delete();
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
return view('livewire.admin.confirm-delete-datacenter');
}
}

View File

@ -19,6 +19,49 @@ class Datacenters extends Component
#[Validate('nullable|string|max:255')] #[Validate('nullable|string|max:255')]
public string $location = ''; public string $location = '';
// Inline edit state (code is immutable once created — it is referenced by hosts).
public ?string $editingUuid = null;
public string $editName = '';
public string $editLocation = '';
public function edit(string $uuid): void
{
$dc = Datacenter::query()->where('uuid', $uuid)->first();
if ($dc === null) {
return;
}
$this->editingUuid = $uuid;
$this->editName = $dc->name;
$this->editLocation = (string) $dc->location;
}
public function cancelEdit(): void
{
$this->reset('editingUuid', 'editName', 'editLocation');
}
public function update(): void
{
if ($this->editingUuid === null) {
return;
}
$data = $this->validate([
'editName' => 'required|string|max:255',
'editLocation' => 'nullable|string|max:255',
]);
Datacenter::query()->where('uuid', $this->editingUuid)->update([
'name' => $data['editName'],
'location' => $data['editLocation'] ?: null,
]);
$this->cancelEdit();
$this->dispatch('notify', message: __('datacenters.updated'));
}
public function save(): void public function save(): void
{ {
// Normalize before validating so the unique check matches how the row is // Normalize before validating so the unique check matches how the row is

View File

@ -60,22 +60,13 @@ class Overview extends Component
'plugins' => ['legend' => ['display' => false]], 'plugins' => ['legend' => ['display' => false]],
], ],
], ],
'hostChart' => [ // Per-host storage load as capacity meters (a ranked comparison, not a
'type' => 'bar', // trend — so meters, not a chart). level drives the meter colour.
'data' => [ 'hostLoad' => [
'labels' => ['pve-fsn-1', 'pve-fsn-2', 'pve-fsn-3', 'pve-hel-1'], ['name' => 'pve-fsn-1', 'pct' => 72, 'level' => 'warn'],
'datasets' => [[ ['name' => 'pve-fsn-2', 'pct' => 64, 'level' => 'ok'],
'label' => '%', ['name' => 'pve-fsn-3', 'pct' => 81, 'level' => 'high'],
'data' => [72, 64, 81, 38], ['name' => 'pve-hel-1', 'pct' => 38, 'level' => 'ok'],
'backgroundColor' => ['token:accent/0.85', 'token:accent/0.85', 'token:danger/0.85', 'token:success-bright/0.85'],
'borderRadius' => 3,
]],
],
'options' => [
'indexAxis' => 'y',
'scales' => ['x' => ['max' => 100, 'grid' => ['color' => 'token:border']], 'y' => ['grid' => ['display' => false]]],
'plugins' => ['legend' => ['display' => false]],
],
], ],
'runs' => [ 'runs' => [
['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'], ['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'],

View File

@ -15,4 +15,15 @@ return [
'inactive' => 'Inaktiv', 'inactive' => 'Inaktiv',
'add' => 'Rechenzentrum anlegen', 'add' => 'Rechenzentrum anlegen',
'created' => 'Rechenzentrum angelegt.', 'created' => 'Rechenzentrum angelegt.',
'updated' => 'Rechenzentrum aktualisiert.',
'actions' => 'Aktionen',
'edit' => 'Bearbeiten',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'delete' => 'Löschen',
'delete_title' => 'Rechenzentrum löschen?',
'delete_body' => '„:name" wird dauerhaft entfernt. Das kann nicht rückgängig gemacht werden.',
'delete_confirm' => 'Endgültig löschen',
'delete_blocked_title' => 'Löschen nicht möglich',
'delete_blocked_body' => '„:name" hat noch :count Host(s). Entferne oder verschiebe zuerst die Hosts.',
]; ];

View File

@ -15,4 +15,15 @@ return [
'inactive' => 'Inactive', 'inactive' => 'Inactive',
'add' => 'Add datacenter', 'add' => 'Add datacenter',
'created' => 'Datacenter created.', 'created' => 'Datacenter created.',
'updated' => 'Datacenter updated.',
'actions' => 'Actions',
'edit' => 'Edit',
'save' => 'Save',
'cancel' => 'Cancel',
'delete' => 'Delete',
'delete_title' => 'Delete datacenter?',
'delete_body' => '“:name” will be permanently removed. This cannot be undone.',
'delete_confirm' => 'Delete permanently',
'delete_blocked_title' => 'Cannot delete',
'delete_blocked_body' => '“:name” still has :count host(s). Remove or move the hosts first.',
]; ];

View File

@ -0,0 +1,32 @@
<div class="rounded-xl bg-surface p-6">
@if ($hostCount > 0)
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.delete_blocked_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('datacenters.delete_blocked_body', ['name' => $name, 'count' => $hostCount]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
</div>
@else
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-danger-bg text-danger">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('datacenters.delete_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('datacenters.delete_body', ['name' => $name]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="$dispatch('closeModal')">{{ __('datacenters.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="delete" wire:loading.attr="disabled">
<x-ui.icon name="trash-2" class="size-4" />{{ __('datacenters.delete_confirm') }}
</x-ui.button>
</div>
@endif
</div>

View File

@ -17,12 +17,30 @@
<th class="px-4 py-3 font-semibold">{{ __('datacenters.name') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.name') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('datacenters.hosts') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.hosts') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('datacenters.status') }}</th> <th class="px-4 py-3 font-semibold">{{ __('datacenters.status') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('datacenters.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach ($datacenters as $dc) @foreach ($datacenters as $dc)
<tr class="border-b border-line last:border-0"> <tr wire:key="dc-{{ $dc->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td> <td class="px-4 py-3 font-mono font-semibold text-ink">{{ $dc->code }}</td>
@if ($editingUuid === $dc->uuid)
<td class="px-4 py-2" colspan="2">
<div class="flex flex-wrap items-center gap-2">
<input type="text" wire:model="editName" aria-label="{{ __('datacenters.name') }}"
class="min-w-32 flex-1 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-ink" />
<input type="text" wire:model="editLocation" aria-label="{{ __('datacenters.location') }}" placeholder="{{ __('datacenters.location') }}"
class="w-24 rounded-md border border-line-strong bg-surface px-2.5 py-1.5 text-sm text-ink" />
</div>
@error('editName')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</td>
<td class="px-4 py-2 text-right">
<div class="inline-flex gap-1.5">
<button type="button" wire:click="update" class="rounded-md bg-accent-active px-2.5 py-1.5 text-xs font-semibold text-on-accent hover:bg-accent-press">{{ __('datacenters.save') }}</button>
<button type="button" wire:click="cancelEdit" class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:bg-surface-hover">{{ __('datacenters.cancel') }}</button>
</div>
</td>
@else
<td class="px-4 py-3 text-body">{{ $dc->name }} <td class="px-4 py-3 text-body">{{ $dc->name }}
@if ($dc->location)<span class="text-xs text-faint">· {{ $dc->location }}</span>@endif @if ($dc->location)<span class="text-xs text-faint">· {{ $dc->location }}</span>@endif
</td> </td>
@ -35,6 +53,20 @@
{{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }} {{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }}
</button> </button>
</td> </td>
<td class="px-4 py-3 text-right">
<div class="inline-flex gap-1">
<button type="button" wire:click="edit('{{ $dc->uuid }}')" aria-label="{{ __('datacenters.edit') }}"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="pen" class="size-4" />
</button>
<button type="button" aria-label="{{ __('datacenters.delete') }}"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-delete-datacenter', arguments: { uuid: '{{ $dc->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted hover:border-danger hover:text-danger">
<x-ui.icon name="trash-2" class="size-4" />
</button>
</div>
</td>
@endif
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>

View File

@ -29,17 +29,30 @@
<p class="text-sm text-muted">{{ __('admin.fleet_growth_sub') }}</p> <p class="text-sm text-muted">{{ __('admin.fleet_growth_sub') }}</p>
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$fleetChart" class="h-56" :label="__('admin.fleet_growth')" /></div> <div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$fleetChart" class="h-56" :label="__('admin.fleet_growth')" /></div>
</div> </div>
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]"> <div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.host_load') }}</h2>
<p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p> <p class="text-sm text-muted">{{ __('admin.host_load_sub') }}</p>
<div class="mt-4 h-56" wire:ignore><x-ui.chart :config="$hostChart" class="h-56" :label="__('admin.host_load')" /></div> <ul class="mt-4 flex-1 space-y-3.5">
@foreach ($hostLoad as $h)
@php $bar = ['ok' => 'bg-success-bright', 'warn' => 'bg-accent-active', 'high' => 'bg-danger'][$h['level']] ?? 'bg-accent-active'; @endphp
<li>
<div class="flex items-baseline justify-between text-sm">
<span class="font-mono text-ink">{{ $h['name'] }}</span>
<span class="font-mono text-muted">{{ $h['pct'] }} %</span>
</div>
<div class="mt-1.5 h-2 overflow-hidden rounded-pill bg-surface-2">
<div class="h-full rounded-pill {{ $bar }}" style="width: {{ $h['pct'] }}%"></div>
</div>
</li>
@endforeach
</ul>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2"> <div class="flex flex-col rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:60ms] lg:col-span-2">
<h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2> <h2 class="font-semibold text-ink">{{ __('admin.mrr_trend') }}</h2>
<div class="mt-3 h-52" wire:ignore><x-ui.chart :config="$revenueChart" class="h-52" :label="__('admin.mrr_trend')" /></div> <div class="mt-3 min-h-52 flex-1" wire:ignore><x-ui.chart :config="$revenueChart" class="h-full" :label="__('admin.mrr_trend')" /></div>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">

View File

@ -1,8 +1,10 @@
<?php <?php
use App\Livewire\Admin\ConfirmDeleteDatacenter;
use App\Livewire\Admin\Datacenters; use App\Livewire\Admin\Datacenters;
use App\Livewire\Admin\HostCreate; use App\Livewire\Admin\HostCreate;
use App\Models\Datacenter; use App\Models\Datacenter;
use App\Models\Host;
use App\Models\User; use App\Models\User;
use Livewire\Livewire; use Livewire\Livewire;
@ -36,6 +38,40 @@ it('rejects a duplicate datacenter code', function () {
->assertHasErrors(['code']); ->assertHasErrors(['code']);
}); });
it('edits a datacenter name and location', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old', 'location' => 'XX']);
Livewire::actingAs(admin())
->test(Datacenters::class)
->call('edit', $dc->uuid)
->set('editName', 'Nürnberg')
->set('editLocation', 'DE')
->call('update')
->assertHasNoErrors();
$dc->refresh();
expect($dc->name)->toBe('Nürnberg')->and($dc->location)->toBe('DE')->and($dc->code)->toBe('nbg');
});
it('deletes an unused datacenter', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg']);
Livewire::actingAs(admin())->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid])->call('delete');
expect(Datacenter::query()->where('uuid', $dc->uuid)->exists())->toBeFalse();
});
it('refuses to delete a datacenter that still has hosts', function () {
$dc = Datacenter::factory()->create(['code' => 'nbg']);
Host::factory()->create(['datacenter' => 'nbg']);
Livewire::actingAs(admin())->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid])
->assertSet('hostCount', 1)
->call('delete');
expect(Datacenter::query()->where('uuid', $dc->uuid)->exists())->toBeTrue();
});
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]);