diff --git a/app/Livewire/Admin/Capacity.php b/app/Livewire/Admin/Capacity.php index b931668..3403daa 100644 --- a/app/Livewire/Admin/Capacity.php +++ b/app/Livewire/Admin/Capacity.php @@ -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 diff --git a/app/Livewire/Admin/ConfirmReleaseReservation.php b/app/Livewire/Admin/ConfirmReleaseReservation.php new file mode 100644 index 0000000..88f0dd0 --- /dev/null +++ b/app/Livewire/Admin/ConfirmReleaseReservation.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index 9d5ba73..a8fc908 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -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(), ]); } } diff --git a/app/Livewire/Admin/Hosts.php b/app/Livewire/Admin/Hosts.php index d8e602c..9a7fc89 100644 --- a/app/Livewire/Admin/Hosts.php +++ b/app/Livewire/Admin/Hosts.php @@ -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)); diff --git a/app/Models/Host.php b/app/Models/Host.php index 299962c..5834dd9 100644 --- a/app/Models/Host.php +++ b/app/Models/Host.php @@ -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 { diff --git a/app/Provisioning/Steps/Customer/ReserveResources.php b/app/Provisioning/Steps/Customer/ReserveResources.php index 0580c7c..aa8a170 100644 --- a/app/Provisioning/Steps/Customer/ReserveResources.php +++ b/app/Provisioning/Steps/Customer/ReserveResources.php @@ -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; diff --git a/app/Services/Provisioning/HostCapacity.php b/app/Services/Provisioning/HostCapacity.php index c35efd1..fa661c8 100644 --- a/app/Services/Provisioning/HostCapacity.php +++ b/app/Services/Provisioning/HostCapacity.php @@ -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(); diff --git a/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php b/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php new file mode 100644 index 0000000..6ff929b --- /dev/null +++ b/database/migrations/2026_08_01_000003_add_reservation_to_hosts.php @@ -0,0 +1,36 @@ +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'); + }); + } +}; diff --git a/lang/de/hosts.php b/lang/de/hosts.php index e0658ed..2f15420 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -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?', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index 138bdbf..685186b 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -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?', diff --git a/resources/views/livewire/admin/capacity.blade.php b/resources/views/livewire/admin/capacity.blade.php index cbcd160..f9515aa 100644 --- a/resources/views/livewire/admin/capacity.blade.php +++ b/resources/views/livewire/admin/capacity.blade.php @@ -107,7 +107,16 @@
@foreach ($hosts as $host){{ __('hosts.release_reservation.body', ['host' => $hostName, 'customer' => $customerName]) }}
+