CluPilotCloud/app/Livewire/Admin/Capacity.php

152 lines
6.0 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
use App\Services\Provisioning\ServerMarket;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Who is waiting for a machine, and what machine would clear them.
*
* A paid order that cannot be placed is parked rather than failed — the
* customer has paid, and "no host has room" is a purchasing decision, not a
* fault. This is where that decision is taken: what is queued, what the
* roomiest host still has, what the queue needs, and what such a machine costs
* today.
*
* Nothing here buys anything. Ordering a server is a contract; a bug in a
* capacity calculation must not be able to sign one.
*/
#[Layout('layouts.admin')]
class Capacity extends Component
{
/**
* Look again now.
*
* The runner retries every couple of minutes on its own. This is for the
* operator who has just finished onboarding a host and does not want to
* wait for the tick to see the queue drain.
*/
public function recheck(): void
{
$this->authorize('hosts.manage');
// Through the same query that FINDS them, so "look again" can never
// mean a different set of runs than the table above it is showing.
app(HostCapacity::class)->parkedRuns()->update(['next_attempt_at' => now()]);
$this->dispatch('notify', message: __('capacity.rechecked'));
}
/**
* Decide which parked order goes on which host.
*
* The queue is read by somebody who knows things the placement rule does
* not — that this customer belongs beside that one, that a machine is about
* to be taken out of service, that the big order should have the new box
* and the small ones can share. The rule stays in charge when nobody says
* otherwise; an empty choice hands it back.
*
* Only the preference is written. The reservation still happens in the
* provisioning step, under the placement lock, and still checks the host
* has room — a console that created the instance itself would be a second
* placement path with no lock and its own arithmetic.
*/
public function pin(int $runId, string $hostId): void
{
$this->authorize('hosts.manage');
// Only a run that is actually parked. Writing a host preference onto a
// run that has moved on would sit in its context for ever and take
// effect on a re-run months later, on a host chosen for a queue that no
// longer exists.
$run = app(HostCapacity::class)->parkedRuns()->whereKey($runId)->first();
if ($run === null) {
return;
}
if ($hostId === '') {
$run->forgetContext('preferred_host_id');
$this->dispatch('notify', message: __('capacity.pin_cleared'));
return;
}
// Nur ein Host, auf dem diese Bestellung auch landen dürfte:
// unreserviert oder ihrem eigenen Kunden reserviert, über denselben
// Scope, den die Platzierung im Schritt benutzt. Eine reservierte
// Maschine gehört ihrem Kunden — sie hier anheften zu lassen hieße, die
// Zusage über die Konsole wieder aufzumachen, und der Schritt lehnte
// die Bestellung danach ohnehin ab.
$host = Host::query()
->where('status', 'active')
->unreserved($run->subject?->customer_id)
->find((int) $hostId);
if ($host === null) {
return;
}
$run->mergeContext(['preferred_host_id' => $host->id]);
// Taken up now rather than at the next tick: an operator who has just
// said where it goes is watching for it to go there.
$run->forceFill(['next_attempt_at' => now()])->save();
$this->dispatch('notify', message: __('capacity.pinned', ['host' => $host->name]));
}
public function render()
{
$this->authorize('hosts.manage');
$capacity = app(HostCapacity::class);
$demand = $capacity->queueDemand();
$parked = $capacity->parked();
// Die Auswahl je Bestellung, nicht eine Liste für alle: welcher Host in
// Frage kommt, hängt am Kunden dieser Bestellung. Gefragt wird über
// denselben Scope wie in der Platzierung — im eigenen Rechenzentrum
// (der Schritt lehnt einen Pin von anderswo ab) und nicht jemand
// anderem reserviert. Ein Auswahlfeld, das anbietet, was die Aktion
// danach ablehnt, ist eine Falle.
//
// Eine Abfrage je Rechenzentrum/Kunde-Paar statt je Zeile: die
// Warteschlange ist die Liste bezahlter Bestellungen ohne Maschine, also
// kurz, und mehrere davon gehören meist demselben Rechenzentrum.
$choices = [];
foreach ($parked as $index => $entry) {
$customerId = $entry['order']->customer_id;
$key = $entry['datacenter'].'|'.$customerId;
$choices[$key] ??= Host::query()
->where('status', 'active')
->where('datacenter', $entry['datacenter'])
->unreserved($customerId)
->orderBy('name')
->get();
$parked[$index]['choices'] = $choices[$key];
}
return view('livewire.admin.capacity', [
'parked' => $parked,
'demand' => $demand,
'unplaceable' => $capacity->unplaceablePlans(),
// 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
? app(ServerMarket::class)->offers($demand['largest'])
: [],
]);
}
}