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
parent
1692fd3977
commit
4ad1828f27
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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?',
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
});
|
||||
Loading…
Reference in New Issue