Ein reservierter Host gehoert seinem Kunden

placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie
largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit —
der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung
ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache.

hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die
Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den
eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der
Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen
laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert,
sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und
Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie
kommentarlos verschwinden zu lassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 16:06:14 +02:00
parent 1692fd3977
commit 4ad1828f27
15 changed files with 414 additions and 5 deletions

View File

@ -101,7 +101,10 @@ class Capacity extends Component
'parked' => $capacity->parked(),
'demand' => $demand,
'unplaceable' => $capacity->unplaceablePlans(),
'hosts' => Host::query()->where('status', 'active')->orderBy('name')->get(),
// reservedFor eager-loaded: die Tafel "was die Hosts noch
// aufnehmen" weist eine reservierte Maschine als solche aus,
// statt sie kommentarlos neben dem allgemeinen Bestand zu zeigen.
'hosts' => Host::query()->where('status', 'active')->orderBy('name')->with('reservedFor')->get(),
// Only when something is actually waiting. A shopping list on an
// idle estate is an invitation to spend money for no reason.
'offers' => $demand['count'] > 0

View File

@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use LivewireUI\Modal\ModalComponent;
/**
* Bestätigung fürs Lösen einer Host-Reservierung (R23).
*
* Mutiert nichts selbst: "der Kunde verliert seine eigene Maschine" bleibt an
* der Stelle geprüft und ausgeführt, an der sie schon steht
* HostDetail::releaseReservation(). Der Bestätigen-Knopf löst nur das
* Ereignis aus, das die Seiten-Komponente per #[On(...)] auffängt und
* weiterreicht dieselbe Berechtigungsprüfung bleibt an der einen Stelle,
* statt sie hier zu verdoppeln.
*/
class ConfirmReleaseReservation extends ModalComponent
{
public string $uuid;
public string $hostName = '';
public string $customerName = '';
public function mount(string $uuid): void
{
// Ein Modal ist ohne die Route-Middleware der Seite erreichbar (R20),
// und zeigt hier den Namen des Kunden, der die Maschine verliert.
$this->authorize('hosts.manage');
$host = Host::query()->where('uuid', $uuid)->with('reservedFor')->first();
$this->uuid = $uuid;
$this->hostName = $host?->name ?? '';
$this->customerName = $host?->reservedFor?->name ?? '';
}
public function confirmRelease(): void
{
$this->authorize('hosts.manage');
$this->dispatch('host-reservation-release-confirmed');
$this->closeModal();
}
public function render()
{
return view('livewire.admin.confirm-release-reservation');
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
@ -67,6 +68,46 @@ class HostDetail extends Component
}
}
/**
* "Eigener Server" verkauft, ab jetzt auch Tatsache: der Host gehört
* diesem Kunden, und Host::placeableIn() hält ab sofort alle anderen
* fern. Braucht keine Bestätigung niemandem wird etwas weggenommen.
*/
public function reserveFor(string $customerUuid): void
{
$this->authorize('hosts.manage');
$customer = Customer::query()->where('uuid', $customerUuid)->first();
if ($customer === null) {
return;
}
$this->host->update(['reserved_for_customer_id' => $customer->id]);
$this->dispatch('notify', message: __('hosts.detail.reserved_saved', ['name' => $customer->name]));
}
/**
* Gibt die Maschine an den allgemeinen Bestand zurück.
*
* Nicht automatisch beim Kundenaustritt (siehe Migrationskommentar): sonst
* verschwände eine Maschine kommentarlos aus dem Bestand, ohne dass ein
* Operator sie tatsächlich freigegeben hat.
*/
public function releaseReservation(): void
{
$this->authorize('hosts.manage');
$this->host->update(['reserved_for_customer_id' => null]);
$this->dispatch('notify', message: __('hosts.detail.reserved_released'));
}
/** ConfirmReleaseReservation dispatches this back (R23) — see that class. */
#[On('host-reservation-release-confirmed')]
public function onReleaseConfirmed(): void
{
$this->releaseReservation();
}
public function retry(): void
{
$this->authorize('hosts.manage');
@ -161,6 +202,10 @@ class HostDetail extends Component
],
'version' => PveVersion::parse($this->host->pve_version),
'fqdn' => HostName::fqdn($this->host->name),
// Nur gebraucht, solange der Host noch niemandem gehört — aber
// billig genug (eine Namensliste), um sie immer mitzugeben statt
// eine zweite Bedingung ums Laden zu ziehen.
'customers' => Customer::query()->orderBy('name')->get(),
]);
}
}

View File

@ -38,7 +38,7 @@ class Hosts extends Component
// call — the console container has no wg0 (see VpnPeer::vpnPeer()
// and SyncVpnPeers' docblock). One eager-loaded query for the
// whole page, not one per host.
->with('vpnPeer')
->with('vpnPeer', 'reservedFor')
->when($this->search !== '', function ($q) {
$term = '%'.$this->search.'%';
$q->where(fn ($w) => $w->where('name', 'like', $term)->orWhere('public_ip', 'like', $term));

View File

@ -9,6 +9,7 @@ use Database\Factories\HostFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
@ -23,6 +24,7 @@ class Host extends Model implements ProvisioningSubject
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
'reserved_for_customer_id',
];
protected $hidden = ['api_token_ref'];
@ -102,6 +104,17 @@ class Host extends Model implements ProvisioningSubject
return $this->belongsToMany(MaintenanceWindow::class);
}
/**
* Wem diese Maschine als "eigener Server" versprochen ist null heißt
* allgemeiner Bestand. Bewusst ohne `nullOnDelete`-Kaskade im Modell: die
* Migration löscht die Reservierung schon auf Datenbankebene, wenn der
* Kunde verschwindet (siehe deren Kopfkommentar).
*/
public function reservedFor(): BelongsTo
{
return $this->belongsTo(Customer::class, 'reserved_for_customer_id');
}
/** Free committable storage: total minus reserve. */
public function freeGb(): int
{
@ -225,17 +238,33 @@ class Host extends Model implements ProvisioningSubject
/**
* Placement (spec §1): first active host in the datacenter with enough free
* committable storage. Cluster is ignored in v1.0.
*
* Reservierte Hosts gehören ihrem Kunden. Ohne den dritten Parameter ist
* die Frage "wer aus dem allgemeinen Bestand kann das tragen" und die
* Antwort darf keine Maschine sein, die bereits jemandem versprochen ist.
*/
public static function placeableIn(string $datacenter, int $quotaGb): ?self
public static function placeableIn(string $datacenter, int $quotaGb, ?int $forCustomerId = null): ?self
{
return self::query()
->where('datacenter', $datacenter)
->where('status', 'active')
->unreserved($forCustomerId)
->orderBy('name')
->get()
->first(fn (self $host) => $host->availableGb() >= $quotaGb);
}
/**
* Hosts, die für diese Platzierung in Frage kommen: der allgemeine Bestand
* und die eigenen.
*/
public function scopeUnreserved(Builder $query, ?int $forCustomerId = null): Builder
{
return $query->where(fn (Builder $q) => $q
->whereNull('reserved_for_customer_id')
->when($forCustomerId !== null, fn (Builder $inner) => $inner->orWhere('reserved_for_customer_id', $forCustomerId)));
}
/** Could this particular host take an instance of this size right now? */
public function canTake(int $quotaGb): bool
{

View File

@ -93,7 +93,7 @@ class ReserveResources extends CustomerStep
// answered, and they would find out from the finished instance.
$host = $pinned !== null
? ($pinned->canTake((int) $plan['disk_gb']) ? $pinned : null)
: Host::placeableIn($order->datacenter, (int) $plan['disk_gb']);
: Host::placeableIn($order->datacenter, (int) $plan['disk_gb'], $order->customer_id);
if ($host === null) {
return null;

View File

@ -42,6 +42,9 @@ class HostCapacity
{
return (int) Host::query()
->where('status', 'active')
// Der allgemeine Bestand. Eine reservierte Maschine hat Platz, aber
// nicht für den, der hier fragt.
->unreserved()
->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
->get()
->map(fn (Host $host) => $host->availableGb())
@ -74,6 +77,7 @@ class HostCapacity
// per sellable package before they had done anything at all.
$hasHost = Host::query()
->where('status', 'active')
->unreserved()
->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter))
->exists();

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Wem eine Maschine gehört.
*
* "Eigener Server" war bisher ein Satz im Angebot und nirgends eine Tatsache:
* die Platzierung nimmt den ersten aktiven Host im Rechenzentrum, und die
* Kapazitätszahlen zählen ihn mit. Beides zusammen verkauft denselben Platz
* zweimal.
*
* Null heißt allgemeiner Bestand. Die Beziehung löscht nicht mit dem Kunden:
* ein Host überlebt seinen Mieter, und die Reservierung wird beim Auszug von
* Hand gelöst sonst stünde die Maschine still im Bestand, ohne dass jemand
* sie angeboten hätte.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->foreignId('reserved_for_customer_id')->nullable()->after('datacenter')
->constrained('customers')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->dropConstrainedForeignId('reserved_for_customer_id');
});
}
};

View File

@ -56,6 +56,9 @@ return [
'capacity' => 'Kapazität',
'free' => 'frei',
'unknown' => '—',
// Kurzform für Zeilen (Host-Liste, Kapazitätsseite) — dieselbe wie in
// 'detail.reserved_for', aber klein genug für eine Tabellenzelle.
'reserved_badge' => 'reserviert für :name',
'meta' => [
'cores' => 'Kerne',
'ram' => 'RAM',
@ -106,6 +109,14 @@ return [
'reserve_saved' => 'Reserve aktualisiert.',
'drain' => 'In Wartung',
'activate' => 'Aktivieren',
'reserved_general' => 'Allgemeiner Bestand.',
'reserved_for' => 'Reserviert für :name.',
'reserved_pick' => 'Kunde wählen …',
'reserved_save' => 'Reservieren',
'reserved_saved' => 'Für :name reserviert.',
'reserved_release' => 'Lösen',
'reserved_released' => 'Reservierung gelöst.',
],
'istatus' => [
@ -153,6 +164,12 @@ return [
'secure_host_firewall' => 'Host-Firewall absichern',
'complete_host_onboarding' => 'Onboarding abschließen',
],
'release_reservation' => [
'title' => 'Reservierung lösen?',
'body' => ':host wird wieder allgemeiner Bestand — bisher reserviert für :customer. Der nächste Startkunde kann darauf landen.',
'confirm' => 'Lösen',
],
'reissue' => [
'action' => 'Befehlszeile neu ausstellen',
'title' => 'Neue Befehlszeile ausstellen?',

View File

@ -56,6 +56,9 @@ return [
'capacity' => 'Capacity',
'free' => 'free',
'unknown' => '—',
// Short form for rows (host list, capacity page) — same idea as
// 'detail.reserved_for', just small enough for a table cell.
'reserved_badge' => 'reserved for :name',
'meta' => [
'cores' => 'Cores',
'ram' => 'RAM',
@ -106,6 +109,14 @@ return [
'reserve_saved' => 'Reserve updated.',
'drain' => 'Maintenance',
'activate' => 'Activate',
'reserved_general' => 'General stock.',
'reserved_for' => 'Reserved for :name.',
'reserved_pick' => 'Pick a customer …',
'reserved_save' => 'Reserve',
'reserved_saved' => 'Reserved for :name.',
'reserved_release' => 'Release',
'reserved_released' => 'Reservation released.',
],
'istatus' => [
@ -153,6 +164,12 @@ return [
'secure_host_firewall' => 'Secure host firewall',
'complete_host_onboarding' => 'Complete onboarding',
],
'release_reservation' => [
'title' => 'Release the reservation?',
'body' => ':host becomes general stock again — previously reserved for :customer. The next new customer could land on it.',
'confirm' => 'Release',
],
'reissue' => [
'action' => 'Re-issue the command line',
'title' => 'Issue a new command line?',

View File

@ -107,7 +107,16 @@
<tbody>
@foreach ($hosts as $host)
<tr class="border-b border-line last:border-0">
<td class="py-3 font-medium text-ink">{{ $host->name }}</td>
<td class="py-3 font-medium text-ink">
{{ $host->name }}
{{-- Erklärt statt versteckt: largestPlaceableGb() unten zählt
diese Zeile nicht mit sie gehört bereits einem Kunden. --}}
@if ($host->reserved_for_customer_id)
<span class="ml-2 inline-flex items-center gap-1 rounded-pill bg-surface-2 px-2 py-0.5 text-xs font-normal text-muted">
<x-ui.icon name="lock" class="size-3.5 shrink-0" />{{ __('hosts.reserved_badge', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }}
</span>
@endif
</td>
<td class="py-3 font-mono text-xs text-muted">{{ $host->datacenter }}</td>
<td class="py-3 text-right font-mono tabular-nums text-body">
{{ $host->availableGb() }} / {{ $host->freeGb() }} GB {{ __('capacity.free') }}

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<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="unlock" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('hosts.release_reservation.title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('hosts.release_reservation.body', ['host' => $hostName, 'customer' => $customerName]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('hosts.cancel') }}</x-ui.button>
<x-ui.button variant="danger" wire:click="confirmRelease" wire:loading.attr="disabled">
{{ __('hosts.release_reservation.confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -195,6 +195,34 @@
<span class="text-xs text-muted">{{ __('hosts.detail.reserve_hint') }}</span>
</div>
@endif
{{-- Reservierung: "eigener Server" ist sonst nur ein Satz im Angebot.
Erst diese Markierung haelt andere Kunden fern (Host::placeableIn()). --}}
<div class="mt-5 flex flex-wrap items-center gap-2 border-t border-line pt-4">
@if ($host->reserved_for_customer_id)
<span class="text-xs text-muted">{{ __('hosts.detail.reserved_for', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }}</span>
<button type="button"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-reservation', arguments: { uuid: '{{ $host->uuid }}' } })"
class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-danger-border hover:text-danger">
{{ __('hosts.detail.reserved_release') }}
</button>
@else
<span class="text-xs text-muted">{{ __('hosts.detail.reserved_general') }}</span>
<div x-data="{ pick: '' }" class="flex flex-wrap items-center gap-2">
<select x-model="pick" aria-label="{{ __('hosts.detail.reserved_pick') }}"
class="rounded-md border border-line-strong bg-surface px-2 py-1 text-xs text-body">
<option value="">{{ __('hosts.detail.reserved_pick') }}</option>
@foreach ($customers as $customer)
<option value="{{ $customer->uuid }}">{{ $customer->name }} ({{ $customer->email }})</option>
@endforeach
</select>
<button type="button" x-on:click="pick && $wire.reserveFor(pick)"
class="rounded-md border border-line px-2 py-1 text-xs font-semibold text-body hover:border-accent-border hover:text-accent-text">
{{ __('hosts.detail.reserved_save') }}
</button>
</div>
@endif
</div>
</div>
{{-- Hosted instances. Die Anzahl steht in der Überschrift, statt als eigene

View File

@ -121,6 +121,13 @@
@else
<span class="text-xs text-muted">{{ __('hosts.unknown') }}</span>
@endif
{{-- Steht getrennt da, statt in der Zahl darüber zu verschwinden:
diese Maschine zählt nicht zum allgemeinen Bestand (Host::placeableIn()). --}}
@if ($host->reserved_for_customer_id)
<span class="mt-1 flex items-center gap-1 text-xs text-muted">
<x-ui.icon name="lock" class="size-3.5 shrink-0" />{{ __('hosts.reserved_badge', ['name' => $host->reservedFor?->name ?? __('hosts.unknown')]) }}
</span>
@endif
</td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
</tr>

View File

@ -0,0 +1,147 @@
<?php
use App\Livewire\Admin\ConfirmReleaseReservation;
use App\Livewire\Admin\HostDetail;
use App\Models\Customer;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use Livewire\Livewire;
/**
* Ein Host, der jemandem gehört.
*
* Enterprise verkauft eine eigene Maschine. Ohne Markierung landet der nächste
* Startkunde darauf, und die Kapazitätszahlen versprechen den Platz obendrein
* ein zweites Mal.
*/
it('platziert keinen fremden Kunden auf einem reservierten Host', function () {
$mieter = Customer::factory()->create();
$fremder = Customer::factory()->create();
Host::factory()->active()->create([
'datacenter' => 'fsn', 'total_gb' => 4000,
'reserved_for_customer_id' => $mieter->id,
]);
expect(Host::placeableIn('fsn', 100, $fremder->id))->toBeNull()
->and(Host::placeableIn('fsn', 100, $mieter->id))->not->toBeNull();
});
it('zählt einen reservierten Host nicht zum allgemeinen Bestand', function () {
$mieter = Customer::factory()->create();
Host::factory()->active()->create([
'datacenter' => 'fsn', 'total_gb' => 4000,
'reserved_for_customer_id' => $mieter->id,
]);
expect(app(HostCapacity::class)->largestPlaceableGb('fsn'))->toBe(0)
->and(app(HostCapacity::class)->canPlace('fsn', 40))->toBeFalse();
});
it('lässt einen freien Host frei', function () {
Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 500]);
expect(app(HostCapacity::class)->canPlace('fsn', 40))->toBeTrue();
});
/**
* Die Konsole: setzen und lösen.
*
* Setzen braucht keine Bestätigung ein Host, der noch niemandem gehört,
* bekommt einen Besitzer, nichts geht verloren. Lösen schon (R23): danach
* ist die Maschine wieder allgemeiner Bestand, und der nächste Startkunde
* kann darauf landen.
*/
it('reserviert einen Host für einen Kunden von der Konsole aus', function () {
$host = Host::factory()->active()->create();
$customer = Customer::factory()->create();
Livewire::actingAs(admin(), 'operator')
->test(HostDetail::class, ['host' => $host])
->call('reserveFor', $customer->uuid)
->assertDispatched('notify');
expect($host->fresh()->reserved_for_customer_id)->toBe($customer->id);
});
it('verweigert das Reservieren ohne hosts.manage', function () {
$host = Host::factory()->active()->create();
$customer = Customer::factory()->create();
Livewire::actingAs(operator('Read-only'), 'operator')
->test(HostDetail::class, ['host' => $host])
->call('reserveFor', $customer->uuid)
->assertForbidden();
expect($host->fresh()->reserved_for_customer_id)->toBeNull();
});
it('bestätigt das Lösen im eigenen Modal, ohne dabei selbst etwas zu ändern', function () {
$mieter = Customer::factory()->create(['name' => 'Acme GmbH']);
$host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]);
Livewire::actingAs(admin(), 'operator')
->test(ConfirmReleaseReservation::class, ['uuid' => $host->uuid])
->assertSee('Acme GmbH')
->call('confirmRelease')
->assertDispatched('host-reservation-release-confirmed');
// Das Modal selbst rührt nichts an — die Reservierung steht noch.
expect($host->fresh()->reserved_for_customer_id)->toBe($mieter->id);
});
it('löst die Reservierung, sobald die Seite das bestätigte Ereignis empfängt', function () {
$mieter = Customer::factory()->create();
$host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]);
Livewire::actingAs(admin(), 'operator')
->test(HostDetail::class, ['host' => $host])
->call('onReleaseConfirmed')
->assertDispatched('notify');
expect($host->fresh()->reserved_for_customer_id)->toBeNull();
});
/**
* Erklärt statt versteckt (Aufgabenauflösung #4): eine reservierte Maschine
* verschwindet nirgends kommentarlos aus dem Bestand Host-Detailseite,
* Host-Liste und Kapazitätsseite weisen sie jeweils als solche aus.
*/
it('zeigt die Reservierung auf der Host-Detailseite', function () {
$mieter = Customer::factory()->create(['name' => 'Contoso KG']);
$host = Host::factory()->active()->create(['reserved_for_customer_id' => $mieter->id]);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))
->assertOk()
->assertSee(__('hosts.detail.reserved_for', ['name' => 'Contoso KG']));
});
it('bietet auf der Host-Detailseite eine Kundenauswahl, solange nichts reserviert ist', function () {
$host = Host::factory()->active()->create();
$customer = Customer::factory()->create(['name' => 'Globex AG']);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts.show', $host))
->assertOk()
->assertSee(__('hosts.detail.reserved_general'))
->assertSee('Globex AG');
});
it('weist die Reservierung auf der Host-Liste aus', function () {
$mieter = Customer::factory()->create(['name' => 'Umbrella Corp']);
Host::factory()->active()->create(['name' => 'pve-reserved-1', 'reserved_for_customer_id' => $mieter->id]);
$this->actingAs(admin(), 'operator')->get(route('admin.hosts'))
->assertOk()
->assertSee(__('hosts.reserved_badge', ['name' => 'Umbrella Corp']));
});
it('weist die Reservierung auf der Kapazitätsseite aus', function () {
App\Models\Datacenter::factory()->create(['code' => 'fsn2']);
$mieter = Customer::factory()->create(['name' => 'Initech Ltd']);
Host::factory()->active()->create(['datacenter' => 'fsn2', 'reserved_for_customer_id' => $mieter->id]);
$this->actingAs(admin(), 'operator')->get(route('admin.capacity'))
->assertOk()
->assertSee(__('hosts.reserved_badge', ['name' => 'Initech Ltd']));
});