diff --git a/app/Livewire/Admin/Datacenters.php b/app/Livewire/Admin/Datacenters.php new file mode 100644 index 0000000..b91a6ac --- /dev/null +++ b/app/Livewire/Admin/Datacenters.php @@ -0,0 +1,49 @@ +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(), + ]); + } +} diff --git a/app/Livewire/Admin/HostCreate.php b/app/Livewire/Admin/HostCreate.php index 9c07e2e..9aeaf76 100644 --- a/app/Livewire/Admin/HostCreate.php +++ b/app/Livewire/Admin/HostCreate.php @@ -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(), ]); } } diff --git a/app/Models/Datacenter.php b/app/Models/Datacenter.php new file mode 100644 index 0000000..862a30b --- /dev/null +++ b/app/Models/Datacenter.php @@ -0,0 +1,31 @@ + */ + 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'); + } +} diff --git a/database/factories/DatacenterFactory.php b/database/factories/DatacenterFactory.php new file mode 100644 index 0000000..0825956 --- /dev/null +++ b/database/factories/DatacenterFactory.php @@ -0,0 +1,22 @@ + */ +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, + ]; + } +} diff --git a/database/migrations/2026_07_25_090001_create_datacenters_table.php b/database/migrations/2026_07_25_090001_create_datacenters_table.php new file mode 100644 index 0000000..4948d35 --- /dev/null +++ b/database/migrations/2026_07_25_090001_create_datacenters_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index cd81d93..fca0c13 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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'], diff --git a/lang/de/admin.php b/lang/de/admin.php index 0285743..63f26e4 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -10,6 +10,7 @@ return [ 'customers' => 'Kunden', 'instances' => 'Instanzen', 'hosts' => 'Hosts', + 'datacenters' => 'Rechenzentren', 'provisioning' => 'Provisioning', 'revenue' => 'Umsatz', ], diff --git a/lang/de/datacenters.php b/lang/de/datacenters.php new file mode 100644 index 0000000..3aa5d8f --- /dev/null +++ b/lang/de/datacenters.php @@ -0,0 +1,18 @@ + '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.', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 5c59b21..1e4fab9 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -10,6 +10,7 @@ return [ 'customers' => 'Customers', 'instances' => 'Instances', 'hosts' => 'Hosts', + 'datacenters' => 'Datacenters', 'provisioning' => 'Provisioning', 'revenue' => 'Revenue', ], diff --git a/lang/en/datacenters.php b/lang/en/datacenters.php new file mode 100644 index 0000000..7fada6c --- /dev/null +++ b/lang/en/datacenters.php @@ -0,0 +1,18 @@ + '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.', +]; diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 7cf5d2f..7b90b33 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -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]) diff --git a/resources/views/livewire/admin/datacenters.blade.php b/resources/views/livewire/admin/datacenters.blade.php new file mode 100644 index 0000000..e97a244 --- /dev/null +++ b/resources/views/livewire/admin/datacenters.blade.php @@ -0,0 +1,56 @@ +
+
+

{{ __('datacenters.title') }}

+

{{ __('datacenters.subtitle') }}

+
+ +
+ {{-- List --}} +
+ @if ($datacenters->isEmpty()) +

{{ __('datacenters.empty') }}

+ @else + + + + + + + + + + + @foreach ($datacenters as $dc) + + + + + + + @endforeach + +
{{ __('datacenters.code') }}{{ __('datacenters.name') }}{{ __('datacenters.hosts') }}{{ __('datacenters.status') }}
{{ $dc->code }}{{ $dc->name }} + @if ($dc->location)· {{ $dc->location }}@endif + {{ $dc->hosts_count }} + +
+ @endif +
+ + {{-- Add form --}} +
+

{{ __('datacenters.add') }}

+ + + + + {{ __('datacenters.add') }} + + +
+
diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index 0fc3224..62c6737 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -15,7 +15,7 @@ @error('datacenter')

{{ $message }}

@enderror diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index e64df21..6b20d0b 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -12,7 +12,7 @@

{{ $host->name }}

{{ __('hosts.status.'.$host->status) }} -

{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}

+

{{ $host->public_ip }} · {{ $host->datacenter }}

@if ($run && $run->status === 'failed') diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index f3a71bd..5c1e98c 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -27,7 +27,7 @@

{{ $host->name }}

-

{{ $host->public_ip }} · {{ __('hosts.datacenter.'.$host->datacenter) }}

+

{{ $host->public_ip }} · {{ $host->datacenter }}

{{ __('hosts.status.'.$host->status) }}
diff --git a/routes/web.php b/routes/web.php index 70f314b..22cdbcd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); diff --git a/tests/Feature/Admin/DatacenterTest.php b/tests/Feature/Admin/DatacenterTest.php new file mode 100644 index 0000000..71e41c1 --- /dev/null +++ b/tests/Feature/Admin/DatacenterTest.php @@ -0,0 +1,61 @@ +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'); +}); diff --git a/tests/Feature/Admin/HostManagementTest.php b/tests/Feature/Admin/HostManagementTest.php index fb46acd..732817e 100644 --- a/tests/Feature/Admin/HostManagementTest.php +++ b/tests/Feature/Admin/HostManagementTest.php @@ -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())