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
parent
e3ab3cb501
commit
54d9a05235
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,49 @@ class Datacenters extends Component
|
|||
#[Validate('nullable|string|max:255')]
|
||||
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
|
||||
{
|
||||
// Normalize before validating so the unique check matches how the row is
|
||||
|
|
|
|||
|
|
@ -60,22 +60,13 @@ class Overview extends Component
|
|||
'plugins' => ['legend' => ['display' => false]],
|
||||
],
|
||||
],
|
||||
'hostChart' => [
|
||||
'type' => 'bar',
|
||||
'data' => [
|
||||
'labels' => ['pve-fsn-1', 'pve-fsn-2', 'pve-fsn-3', 'pve-hel-1'],
|
||||
'datasets' => [[
|
||||
'label' => '%',
|
||||
'data' => [72, 64, 81, 38],
|
||||
'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]],
|
||||
],
|
||||
// Per-host storage load as capacity meters (a ranked comparison, not a
|
||||
// trend — so meters, not a chart). level drives the meter colour.
|
||||
'hostLoad' => [
|
||||
['name' => 'pve-fsn-1', 'pct' => 72, 'level' => 'warn'],
|
||||
['name' => 'pve-fsn-2', 'pct' => 64, 'level' => 'ok'],
|
||||
['name' => 'pve-fsn-3', 'pct' => 81, 'level' => 'high'],
|
||||
['name' => 'pve-hel-1', 'pct' => 38, 'level' => 'ok'],
|
||||
],
|
||||
'runs' => [
|
||||
['customer' => 'Ordination Dr. Fux', 'step' => __('admin.run.deploy'), 'state' => 'running'],
|
||||
|
|
|
|||
|
|
@ -15,4 +15,15 @@ return [
|
|||
'inactive' => 'Inaktiv',
|
||||
'add' => 'Rechenzentrum anlegen',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -15,4 +15,15 @@ return [
|
|||
'inactive' => 'Inactive',
|
||||
'add' => 'Add datacenter',
|
||||
'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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -17,24 +17,56 @@
|
|||
<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.status') }}</th>
|
||||
<th class="px-4 py-3 text-right font-semibold">{{ __('datacenters.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@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 text-body">{{ $dc->name }}
|
||||
@if ($dc->location)<span class="text-xs text-faint">· {{ $dc->location }}</span>@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button type="button" wire:click="toggle('{{ $dc->uuid }}')"
|
||||
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
|
||||
{{ $dc->active ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }}
|
||||
</button>
|
||||
</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 }}
|
||||
@if ($dc->location)<span class="text-xs text-faint">· {{ $dc->location }}</span>@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $dc->hosts_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button type="button" wire:click="toggle('{{ $dc->uuid }}')"
|
||||
class="inline-flex items-center gap-1.5 rounded-pill border px-2.5 py-0.5 text-xs font-medium transition
|
||||
{{ $dc->active ? 'border-success-border bg-success-bg text-success' : 'border-line-strong bg-surface-2 text-muted' }}">
|
||||
<span class="size-1.5 rounded-pill bg-current" aria-hidden="true"></span>
|
||||
{{ $dc->active ? __('datacenters.active') : __('datacenters.inactive') }}
|
||||
</button>
|
||||
</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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -29,17 +29,30 @@
|
|||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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 class="flex flex-col gap-4">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Admin\ConfirmDeleteDatacenter;
|
||||
use App\Livewire\Admin\Datacenters;
|
||||
use App\Livewire\Admin\HostCreate;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
|
@ -36,6 +38,40 @@ it('rejects a duplicate datacenter code', function () {
|
|||
->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 () {
|
||||
$dc = Datacenter::factory()->create(['active' => true]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue