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(HealthService $health): 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); $check = 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']); // Probe the new check right away so its status/latency shows immediately (not only after a // manual refresh / the next scheduled scan). try { $this->results[$check->uuid] = $health->probe($check); } catch (Throwable $e) { $this->results[$check->uuid] = ['ok' => false, 'latency' => null, 'detail' => $e->getMessage()]; } $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()); } }