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
parent
5fb4e637fc
commit
bc278c8fa1
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ return [
|
|||
'customers' => 'Kunden',
|
||||
'instances' => 'Instanzen',
|
||||
'hosts' => 'Hosts',
|
||||
'datacenters' => 'Rechenzentren',
|
||||
'provisioning' => 'Provisioning',
|
||||
'revenue' => 'Umsatz',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -10,6 +10,7 @@ return [
|
|||
'customers' => 'Customers',
|
||||
'instances' => 'Instances',
|
||||
'hosts' => 'Hosts',
|
||||
'datacenters' => 'Datacenters',
|
||||
'provisioning' => 'Provisioning',
|
||||
'revenue' => 'Revenue',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in New Issue