host = $host; // Nothing collected yet? Ask for it now rather than leaving the tiles // empty until the minutely collector next comes round. Opening the page // is exactly the moment somebody wants these figures, and waiting up to // a minute for the first ones reads as "broken", not as "not yet". // // The job is ShouldBeUnique, so a page opened twice does not queue two. $worthAsking = Host::query()->collectable()->whereKey($host->id)->exists(); if ($worthAsking && ! app(HostLoadSeries::class)->forHost($host)['available']) { CollectHostLoad::dispatch(); } } /** Live refresh whenever any run advances (admins-only channel). */ #[On('echo-private:admin.runs,StepAdvanced')] public function onStepAdvanced(): void { $this->host->refresh(); } /** Adjust the capacity reserve (% of storage kept free for headroom). */ public function saveReserve(int $reserve): void { $this->authorize('hosts.manage'); $reserve = max(0, min(90, $reserve)); $this->host->update(['reserve_pct' => $reserve]); $this->dispatch('notify', message: __('hosts.detail.reserve_saved')); } /** * Drain / return a host: toggle between active and disabled. Disabled takes * it out of placement (maintenance) without purging it — distinct from the * destructive "remove host". Never touches a host mid-onboarding. */ public function toggleMaintenance(): void { $this->authorize('hosts.manage'); if ($this->host->status === 'active') { $this->host->update(['status' => 'disabled']); } elseif ($this->host->status === 'disabled') { $this->host->update(['status' => 'active']); } } /** * "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'); $run = $this->currentRun(); if ($run !== null && $run->status === ProvisioningRun::STATUS_FAILED) { $run->update([ 'status' => ProvisioningRun::STATUS_RUNNING, 'attempt' => 0, 'next_attempt_at' => now(), 'started_at' => now(), // reset the step timer so it doesn't re-time-out instantly 'error' => null, ]); $this->host->update(['status' => 'onboarding']); AdvanceRunJob::dispatch($run->uuid); } } private function currentRun(): ?ProvisioningRun { return $this->host->runs()->latest('id')->first(); } /** @return array */ private function buildSteps(?ProvisioningRun $run): array { $pipeline = config('provisioning.pipelines.host', []); $current = $run?->current_step ?? 0; $status = $run?->status; $steps = []; foreach ($pipeline as $index => $class) { if ($status === ProvisioningRun::STATUS_COMPLETED || $index < $current) { $state = 'done'; } elseif ($index === $current) { $state = $status === ProvisioningRun::STATUS_FAILED ? 'failed' : 'running'; } else { $state = 'pending'; } $steps[] = ['label' => __(app($class)->label()), 'state' => $state]; } return $steps; } /** * The most recent reading that is actually a reading. * * Walked from the back rather than taking the last element: Proxmox omits * the fields for a sample it has no data for, so the newest entry is quite * often a gap — and a tile that showed 0 % because of it would be stating * something about the host that nobody measured. * * @param array $series */ private function latest(array $series): ?float { for ($i = count($series) - 1; $i >= 0; $i--) { if ($series[$i] !== null) { return $series[$i]; } } return null; } public function render() { $run = $this->currentRun(); /** @var Collection $events */ $events = $run ? $run->events()->latest('id')->limit(30)->get() : collect(); $load = app(HostLoadSeries::class)->forHost($this->host); return view('livewire.admin.host-detail', [ 'run' => $run, 'steps' => $this->buildSteps($run), 'events' => $events, 'instances' => $this->host->instances()->latest('id')->get(), 'health' => $this->host->healthState(), 'load' => $load, // Eine Zahl je Kachel, aus derselben Reihe, die die Kachel zeichnet. 'now' => [ 'cpu' => $this->latest($load['cpu']), 'ram' => $this->latest($load['ram']), 'netin' => $this->latest($load['netin']), 'netout' => $this->latest($load['netout']), ], '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(), ]); } }