159 lines
4.8 KiB
PHP
159 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Health;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\HealthCheck;
|
|
use App\Services\HealthService;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Uptime / health status board. `operate`-gated (route + mount + per-method). Probes run NATIVELY
|
|
* (HealthService — Laravel HTTP client / a PHP TCP stream, no SSH/shell). The lazy scan is
|
|
* per-check guarded. Deleting a check goes through a signed ConfirmToken + R5 modal.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
// Add-check form
|
|
public string $label = '';
|
|
|
|
public string $type = 'http';
|
|
|
|
public string $target = '';
|
|
|
|
public ?int $port = null;
|
|
|
|
/** @var array<string, array{ok:bool, latency:?int, detail:string}> check uuid → probe result */
|
|
public array $results = [];
|
|
|
|
public bool $ready = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('operate'), 403);
|
|
}
|
|
|
|
public function title(): string
|
|
{
|
|
return __('health.title');
|
|
}
|
|
|
|
private function gate(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('operate'), 403);
|
|
}
|
|
|
|
private function audit(string $action, string $target): void
|
|
{
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => $action,
|
|
'target' => $target,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
public function scan(HealthService $health): void
|
|
{
|
|
$this->gate();
|
|
$this->results = [];
|
|
|
|
foreach (HealthCheck::orderBy('label')->orderBy('target')->get() as $check) {
|
|
try {
|
|
$this->results[$check->uuid] = $health->probe($check);
|
|
} catch (Throwable $e) {
|
|
$this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
$this->ready = true;
|
|
}
|
|
|
|
public function addCheck(): void
|
|
{
|
|
$this->gate();
|
|
|
|
// HTTP → a real http(s) URL; TCP → a hostname/IP + a port. Neither is ever shelled.
|
|
$rules = [
|
|
'label' => ['nullable', 'string', 'max:60'],
|
|
'type' => ['required', Rule::in(HealthCheck::TYPES)],
|
|
];
|
|
if ($this->type === 'tcp') {
|
|
$rules['target'] = ['required', 'string', 'max:253', 'regex:/^[A-Za-z0-9]([A-Za-z0-9.\-]*[A-Za-z0-9])?$/'];
|
|
$rules['port'] = ['required', 'integer', 'min:1', 'max:65535'];
|
|
} else {
|
|
$rules['target'] = ['required', 'url:http,https', 'max:2048'];
|
|
$rules['port'] = ['nullable'];
|
|
}
|
|
|
|
$data = $this->validate($rules);
|
|
|
|
HealthCheck::create([
|
|
'label' => $data['label'] ?? null,
|
|
'type' => $data['type'],
|
|
'target' => $data['target'],
|
|
'port' => $this->type === 'tcp' ? $data['port'] : null,
|
|
]);
|
|
|
|
$this->audit('health.check_add', $data['target']);
|
|
$this->reset('label', 'target', 'port');
|
|
$this->type = 'http';
|
|
$this->dispatch('notify', message: __('health.added'));
|
|
}
|
|
|
|
public function confirmDeleteCheck(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$check = HealthCheck::where('uuid', $uuid)->firstOrFail();
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('health.delete_heading'),
|
|
'body' => __('health.delete_body', ['target' => $check->target]),
|
|
'confirmLabel' => __('common.delete'),
|
|
'danger' => true,
|
|
'icon' => 'trash',
|
|
'notify' => __('health.deleted'),
|
|
'token' => ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('healthCheckDeleted')]
|
|
public function deleteCheck(string $confirmToken): void
|
|
{
|
|
$this->gate();
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'healthCheckDeleted');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
HealthCheck::where('uuid', $payload['params']['uuid'])->first()?->delete();
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
$checks = HealthCheck::orderBy('label')->orderBy('target')->get();
|
|
$up = count(array_filter($this->results, fn ($r) => $r['ok'] ?? false));
|
|
|
|
return view('livewire.health.index', [
|
|
'checks' => $checks,
|
|
'up' => $up,
|
|
'total' => count($this->results),
|
|
])->title($this->title());
|
|
}
|
|
}
|