feat(admin): datacenters management (create + select)

datacenters table/model + admin CRUD (/admin/datacenters, nav). HostCreate
picks from active datacenters (validated); hosts show the datacenter code.
Seeded fsn/hel. 5 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 13:25:17 +02:00
parent 5fb4e637fc
commit bc278c8fa1
18 changed files with 304 additions and 6 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.admin')]
class Datacenters extends Component
{
#[Validate('required|string|max:8|alpha_dash|unique:datacenters,code')]
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('nullable|string|max:255')]
public string $location = '';
public function save(): void
{
$data = $this->validate();
Datacenter::create([
'code' => strtolower($data['code']),
'name' => $data['name'],
'location' => $data['location'] ?: null,
'active' => true,
]);
$this->reset('code', 'name', 'location');
$this->dispatch('notify', message: __('datacenters.created'));
}
public function toggle(string $uuid): void
{
$datacenter = Datacenter::query()->where('uuid', $uuid)->first();
$datacenter?->update(['active' => ! $datacenter->active]);
}
public function render()
{
return view('livewire.admin.datacenters', [
'datacenters' => Datacenter::query()->withCount('hosts')->orderBy('name')->get(),
]);
}
}

View File

@ -13,8 +13,13 @@ class HostCreate extends Component
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|in:fsn,hel')]
public string $datacenter = 'fsn';
#[Validate('required|string|exists:datacenters,code')]
public string $datacenter = '';
public function mount(): void
{
$this->datacenter = (string) \App\Models\Datacenter::query()->active()->orderBy('name')->value('code');
}
#[Validate('required|ip|unique:hosts,public_ip')]
public string $public_ip = '';
@ -34,7 +39,7 @@ class HostCreate extends Component
public function render()
{
return view('livewire.admin.host-create', [
'datacenters' => ['fsn', 'hel'],
'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
]);
}
}

31
app/Models/Datacenter.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Datacenter extends Model
{
/** @use HasFactory<\Database\Factories\DatacenterFactory> */
use HasFactory, HasUuid;
protected $fillable = ['code', 'name', 'location', 'active'];
protected function casts(): array
{
return ['active' => 'boolean'];
}
public function scopeActive(Builder $query): Builder
{
return $query->where('active', true);
}
public function hosts()
{
return $this->hasMany(Host::class, 'datacenter', 'code');
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Datacenter;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Datacenter> */
class DatacenterFactory extends Factory
{
protected $model = Datacenter::class;
public function definition(): array
{
return [
'code' => $this->faker->unique()->lexify('dc?'),
'name' => $this->faker->city(),
'location' => 'DE',
'active' => true,
];
}
}

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('datacenters', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('code')->unique(); // e.g. fsn, hel — used by hosts.datacenter + placement
$table->string('name');
$table->string('location')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('datacenters');
}
};

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Customer;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
@ -42,6 +43,11 @@ class DatabaseSeeder extends Seeder
],
);
// Datacenters — hosts + orders pick their code.
foreach ([['fsn', 'Falkenstein', 'DE'], ['hel', 'Helsinki', 'FI']] as [$dcCode, $dcName, $dcLocation]) {
Datacenter::updateOrCreate(['code' => $dcCode], ['name' => $dcName, 'location' => $dcLocation, 'active' => true]);
}
// Demo fleet so the operator console hosts view has content locally.
$fleet = [
['name' => 'pve-fsn-1', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.11', 'wg_ip' => '10.66.0.2'],

View File

@ -10,6 +10,7 @@ return [
'customers' => 'Kunden',
'instances' => 'Instanzen',
'hosts' => 'Hosts',
'datacenters' => 'Rechenzentren',
'provisioning' => 'Provisioning',
'revenue' => 'Umsatz',
],

18
lang/de/datacenters.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'title' => 'Rechenzentren',
'subtitle' => 'Standorte anlegen und verwalten — Hosts und Bestellungen wählen daraus.',
'empty' => 'Noch keine Rechenzentren. Lege dein erstes an.',
'code' => 'Kürzel',
'code_hint' => 'Kurz & eindeutig, z. B. fsn, hel, nbg.',
'name' => 'Name',
'location' => 'Standort',
'location_hint' => 'Optional, z. B. DE, Falkenstein.',
'hosts' => 'Hosts',
'status' => 'Status',
'active' => 'Aktiv',
'inactive' => 'Inaktiv',
'add' => 'Rechenzentrum anlegen',
'created' => 'Rechenzentrum angelegt.',
];

View File

@ -10,6 +10,7 @@ return [
'customers' => 'Customers',
'instances' => 'Instances',
'hosts' => 'Hosts',
'datacenters' => 'Datacenters',
'provisioning' => 'Provisioning',
'revenue' => 'Revenue',
],

18
lang/en/datacenters.php Normal file
View File

@ -0,0 +1,18 @@
<?php
return [
'title' => 'Datacenters',
'subtitle' => 'Create and manage locations — hosts and orders pick from these.',
'empty' => 'No datacenters yet. Add your first one.',
'code' => 'Code',
'code_hint' => 'Short & unique, e.g. fsn, hel, nbg.',
'name' => 'Name',
'location' => 'Location',
'location_hint' => 'Optional, e.g. DE, Falkenstein.',
'hosts' => 'Hosts',
'status' => 'Status',
'active' => 'Active',
'inactive' => 'Inactive',
'add' => 'Add datacenter',
'created' => 'Datacenter created.',
];

View File

@ -30,6 +30,7 @@
['admin.customers', 'users', 'customers'],
['admin.instances', 'box', 'instances'],
['admin.hosts', 'server', 'hosts'],
['admin.datacenters', 'database', 'datacenters'],
['admin.provisioning', 'activity', 'provisioning'],
['admin.revenue', 'trending-up', 'revenue'],
] as [$route, $icon, $key])

View File

@ -0,0 +1,56 @@
<div class="space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-semibold tracking-tight text-ink">{{ __('datacenters.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('datacenters.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
{{-- List --}}
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
@if ($datacenters->isEmpty())
<p class="p-8 text-center text-sm text-muted">{{ __('datacenters.empty') }}</p>
@else
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-faint">
<th class="px-4 py-3 font-semibold">{{ __('datacenters.code') }}</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.status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($datacenters as $dc)
<tr 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>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
{{-- Add form --}}
<form wire:submit="save" class="space-y-4 rounded-xl border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:120ms]">
<h2 class="font-semibold text-ink">{{ __('datacenters.add') }}</h2>
<x-ui.input name="code" wire:model="code" :label="__('datacenters.code')" :hint="__('datacenters.code_hint')" />
<x-ui.input name="name" wire:model="name" :label="__('datacenters.name')" placeholder="Falkenstein" />
<x-ui.input name="location" wire:model="location" :label="__('datacenters.location')" :hint="__('datacenters.location_hint')" />
<x-ui.button variant="primary" type="submit" class="w-full">
<x-ui.icon name="plus" class="size-4" />{{ __('datacenters.add') }}
</x-ui.button>
</form>
</div>
</div>

View File

@ -15,7 +15,7 @@
<select id="datacenter" wire:model="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
@foreach ($datacenters as $dc)
<option value="{{ $dc }}">{{ __('hosts.datacenter.'.$dc) }} ({{ $dc }})</option>
<option value="{{ $dc->code }}">{{ $dc->name }} ({{ $dc->code }})</option>
@endforeach
</select>
@error('datacenter') <p class="text-xs text-danger">{{ $message }}</p> @enderror

View File

@ -12,7 +12,7 @@
<h1 class="font-mono text-2xl font-semibold tracking-tight text-ink">{{ $host->name }}</h1>
<x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}</p>
<p class="mt-1 font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
</div>
<div class="flex items-center gap-3">
@if ($run && $run->status === 'failed')

View File

@ -27,7 +27,7 @@
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-text"><x-ui.icon name="server" class="size-5" /></span>
<div class="min-w-0">
<p class="font-mono font-semibold text-ink">{{ $host->name }}</p>
<p class="font-mono text-xs text-muted">{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}</p>
<p class="font-mono text-xs text-muted">{{ $host->public_ip }} · {{ $host->datacenter }}</p>
</div>
<x-ui.badge :status="$badge" class="ml-auto">{{ __('hosts.status.'.$host->status) }}</x-ui.badge>
</div>

View File

@ -51,6 +51,7 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->name('admin.')->group(fun
Route::get('/hosts', Admin\Hosts::class)->name('hosts');
Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create');
Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show');
Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters');
Route::get('/provisioning', Admin\Provisioning::class)->name('provisioning');
Route::get('/revenue', Admin\Revenue::class)->name('revenue');
});

View File

@ -0,0 +1,61 @@
<?php
use App\Livewire\Admin\Datacenters;
use App\Livewire\Admin\HostCreate;
use App\Models\Datacenter;
use App\Models\User;
use Livewire\Livewire;
// admin() helper is defined in HostManagementTest (global Pest function).
it('gates the datacenters page', function () {
$this->get(route('admin.datacenters'))->assertRedirect('/login');
$this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('admin.datacenters'))->assertForbidden();
$this->actingAs(admin())->get(route('admin.datacenters'))->assertOk()->assertSee(__('datacenters.title'));
});
it('creates a datacenter', function () {
Livewire::actingAs(admin())
->test(Datacenters::class)
->set('code', 'NBG')
->set('name', 'Nürnberg')
->set('location', 'DE')
->call('save')
->assertHasNoErrors();
expect(Datacenter::query()->where('code', 'nbg')->where('name', 'Nürnberg')->exists())->toBeTrue();
});
it('rejects a duplicate datacenter code', function () {
Datacenter::factory()->create(['code' => 'fsn']);
Livewire::actingAs(admin())
->test(Datacenters::class)
->set('code', 'fsn')->set('name', 'Falkenstein')
->call('save')
->assertHasErrors(['code']);
});
it('toggles a datacenter active flag', function () {
$dc = Datacenter::factory()->create(['active' => true]);
Livewire::actingAs(admin())->test(Datacenters::class)->call('toggle', $dc->uuid);
expect($dc->fresh()->active)->toBeFalse();
});
it('offers only known datacenter codes when adding a host', function () {
Datacenter::factory()->create(['code' => 'fsn', 'name' => 'Falkenstein']);
// unknown code rejected
Livewire::actingAs(admin())
->test(HostCreate::class)
->set('name', 'pve-x')->set('datacenter', 'ghost')->set('public_ip', '203.0.113.5')->set('root_password', 'supersecret')
->call('save')
->assertHasErrors(['datacenter']);
// known code accepted (defaults to the first active datacenter on mount)
Livewire::actingAs(admin())
->test(HostCreate::class)
->assertSet('datacenter', 'fsn');
});

View File

@ -39,6 +39,7 @@ it('lists real hosts for admins', function () {
it('creates a host and starts onboarding with an encrypted password', function () {
Queue::fake();
\App\Models\Datacenter::factory()->create(['code' => 'fsn']);
Livewire::actingAs(admin())
->test(HostCreate::class)
@ -61,6 +62,7 @@ it('creates a host and starts onboarding with an encrypted password', function (
});
it('rejects a duplicate public ip', function () {
\App\Models\Datacenter::factory()->create(['code' => 'fsn']);
Host::factory()->create(['public_ip' => '203.0.113.99']);
Livewire::actingAs(admin())