host = $host; } /** Live refresh whenever any run advances (admins-only channel). */ #[On('echo-private:admin.runs,StepAdvanced')] public function onStepAdvanced(): void { $this->host->refresh(); } /** * The polled tick: re-read the host and push fresh points into the live * chart. * * The dispatch is the only way the curve moves. The canvas sits under * wire:ignore, so re-rendering — which this method also causes — updates * every figure on the page EXCEPT the chart. See x-ui.chart's `update-on`. */ public function refreshLoad(): void { $this->host->refresh(); $load = app(HostLoadSeries::class)->forHost($this->host); $this->dispatch( 'host-load', labels: $this->clockLabels($load['labels']), datasets: [$load['cpu'], $load['ram']], ); } /** 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']); } } 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; } /** * Unix seconds → the wall clock the operator is reading it on (R19). * * @param array $timestamps * @return array */ private function clockLabels(array $timestamps): array { return array_map( fn (int $t) => Carbon::createFromTimestamp($t)->local()->format('H:i'), $timestamps, ); } /** * Chart.js config for the load curve. * * ONE axis, both series in percent. Two measures on two y-scales is the * single most misread thing a chart can do, and here it is not even * tempting: "how full is this host" is the same question for cores and for * memory, so they belong on the same 0–100. * * `token:accent` then `token:info` — the same fixed order every other chart * in the console uses, so a series never changes colour between pages. * Validated as a pair: ΔE 28.3 under protanopia, 39.2 in normal vision. * Accent falls below 3:1 against the surface, which obliges visible labels * rather than colour alone — the panel carries both current values as * labelled figures above the canvas, and the legend names both series. * * @param array{labels: array, cpu: array, ram: array, available: bool} $load * @return array */ private function chartConfig(array $load): array { $line = fn (string $label, array $data, string $token) => [ 'label' => $label, 'data' => $data, 'borderColor' => 'token:'.$token, 'backgroundColor' => 'token:'.$token.'/0.10', 'borderWidth' => 2, 'pointRadius' => 0, 'pointHitRadius' => 12, 'tension' => 0.3, 'fill' => true, // A gap stays a gap. Chart.js would otherwise draw a straight line // across a sample Proxmox never recorded — inventing the very // minutes we know nothing about. 'spanGaps' => false, ]; return [ 'type' => 'line', 'data' => [ 'labels' => $this->clockLabels($load['labels']), 'datasets' => [ $line(__('hosts.detail.cpu'), $load['cpu'], 'accent'), $line(__('hosts.detail.ram'), $load['ram'], 'info'), ], ], 'options' => [ // One shared tooltip for the whole minute rather than one per // line: the question is always "what were both doing then". 'interaction' => ['mode' => 'index', 'intersect' => false], 'plugins' => ['legend' => ['display' => true, 'position' => 'bottom', 'labels' => ['boxWidth' => 8, 'boxHeight' => 8, 'usePointStyle' => true]]], 'scales' => [ 'y' => [ // Pinned to 0–100 on purpose: an auto-scaled axis makes // 3 % look like a wall of load. 'min' => 0, 'max' => 100, 'ticks' => ['stepSize' => 25], 'grid' => ['color' => 'token:border'], ], 'x' => ['grid' => ['display' => false], 'ticks' => ['maxTicksLimit' => 6]], ], ], ]; } 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, 'loadChart' => $this->chartConfig($load), 'version' => PveVersion::parse($this->host->pve_version), ]); } }