CluPilotCloud/app/Livewire/Admin/HostDetail.php

225 lines
7.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Services\Proxmox\HostLoadSeries;
use App\Support\PveVersion;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.admin')]
class HostDetail extends Component
{
public Host $host;
public function mount(Host $host): void
{
$this->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<int, array{label: string, state: string}> */
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<int, int> $timestamps
* @return array<int, string>
*/
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 0100.
*
* `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<int, int>, cpu: array<int, ?float>, ram: array<int, ?float>, available: bool} $load
* @return array<string, mixed>
*/
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 0100 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),
]);
}
}