225 lines
7.9 KiB
PHP
225 lines
7.9 KiB
PHP
<?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 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<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 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),
|
||
]);
|
||
}
|
||
}
|