feat(uptime): HTTP/TCP health checks + status board (feature 8/8)

Uptime monitoring for the services the fleet hosts: HTTP and TCP probes with a live up/down status
board. Completes the monitoring trio (alerts, certs, uptime).

- HealthService::probe(check): native — Laravel HTTP client (withoutRedirecting; reports only the
  status code + latency, never the response body → no internal-content exfil) for http, a PHP TCP
  stream for tcp. No SSH, no shell, so the target is never interpolated into a command. 2xx/3xx = up;
  a 4xx/5xx means "answered but unhealthy"; a connect error = down.
- Health\Index page (route /health, `operate`-gated: route + mount + per-method). Add/remove checks
  (http target validated as a real http(s) URL; tcp target as a hostname/IP + a required port). Lazy
  per-check probe, each guarded. Up/total summary. Delete via signed ConfirmToken + R5 modal.
  Add/delete audited.
- lang/{de,en}/health.php + audit.php health.check_* + shell.nav_health (de/en parity). No emoji.

11 new tests: service (http 2xx up / 5xx down / connection-error down, tcp refused-port down),
component (route gating, add http + audit, reject non-url http target, tcp requires host+port,
reject missing port, scan probes each check, delete via confirmed token). 742 tests green, Pint,
lang parity, self-reviewed. A PUBLIC (unauthenticated) status page is a documented future.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 21:04:14 +02:00
parent 3cab72bf46
commit 65b92bc65c
16 changed files with 632 additions and 0 deletions

View File

@ -0,0 +1,158 @@
<?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());
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** An uptime probe: an HTTP URL or a TCP host:port whose reachability Clusev monitors. */
class HealthCheck extends Model
{
protected $guarded = [];
protected $casts = ['port' => 'integer'];
public const TYPES = ['http', 'tcp'];
protected static function booted(): void
{
static::creating(fn (self $c) => $c->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Services;
use App\Models\HealthCheck;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* Runs an uptime probe NATIVELY (Laravel HTTP client / a PHP TCP stream) no SSH, no shell, so the
* target is never interpolated into a command. An HTTP probe reports only up/down + status code +
* latency (never the response body), so it can't exfiltrate an internal endpoint's content. Redirects
* are disabled so a 30x can't bounce the probe to an unseen target.
*/
class HealthService
{
/**
* @return array{ok:bool, latency:?int, detail:string}
*/
public function probe(HealthCheck $check, int $timeout = 5): array
{
return $check->type === 'tcp'
? $this->tcp((string) $check->target, (int) $check->port, $timeout)
: $this->http((string) $check->target, $timeout);
}
private function http(string $url, int $timeout): array
{
$start = microtime(true);
try {
$res = Http::timeout($timeout)->withoutRedirecting()->get($url);
$latency = (int) round((microtime(true) - $start) * 1000);
$status = $res->status();
// 2xx/3xx = up; a 4xx/5xx means the endpoint answered but is unhealthy.
return ['ok' => $status >= 200 && $status < 400, 'latency' => $latency, 'detail' => 'HTTP '.$status];
} catch (Throwable $e) {
return ['ok' => false, 'latency' => null, 'detail' => $this->reason($e->getMessage())];
}
}
private function tcp(string $host, int $port, int $timeout): array
{
$start = microtime(true);
$errno = 0;
$errstr = '';
$sock = @stream_socket_client('tcp://'.$host.':'.$port, $errno, $errstr, $timeout);
if (! $sock) {
return ['ok' => false, 'latency' => null, 'detail' => $errstr !== '' ? $errstr : 'connection failed'];
}
fclose($sock);
$latency = (int) round((microtime(true) - $start) * 1000);
return ['ok' => true, 'latency' => $latency, 'detail' => 'TCP '.$port.' open'];
}
/** Keep the failure reason short + on one line for the compact status row. */
private function reason(string $msg): string
{
$msg = trim(preg_split('/\R/', $msg)[0] ?? $msg);
return mb_strlen($msg) > 80 ? mb_substr($msg, 0, 80).'…' : $msg;
}
}

View File

@ -72,6 +72,7 @@ class ConfirmToken
'runbookDeleted',
'patchApply',
'certEndpointDeleted',
'healthCheckDeleted',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('health_checks', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('label')->nullable();
$table->string('type')->default('http'); // http | tcp
$table->string('target'); // http: a URL; tcp: a host
$table->unsignedSmallInteger('port')->nullable(); // tcp only
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('health_checks');
}
};

View File

@ -67,6 +67,8 @@ return [
'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
'firewall.rule_delete' => 'Firewall-Regel gelöscht',
'group.create' => 'Server-Gruppe angelegt',
'health.check_add' => 'Verfügbarkeits-Prüfung hinzugefügt',
'health.check_delete' => 'Verfügbarkeits-Prüfung entfernt',
'group.update' => 'Server-Gruppe geändert',
'group.delete' => 'Server-Gruppe gelöscht',
'group.members' => 'Gruppen-Zuordnung geändert',

38
lang/de/health.php Normal file
View File

@ -0,0 +1,38 @@
<?php
return [
'eyebrow' => 'Betrieb',
'title' => 'Verfügbarkeit',
'subtitle' => 'HTTP-/TCP-Erreichbarkeit überwachter Dienste',
'scan_hint' => 'Prüfe Dienste …',
'up_of' => ':up/:total erreichbar',
// Add
'new_check' => 'Neue Prüfung',
'label_label' => 'Bezeichnung',
'type_label' => 'Typ',
'type_http' => 'HTTP',
'type_tcp' => 'TCP',
'target_label' => 'Ziel',
'target_http_placeholder' => 'https://dienst.example.com',
'target_tcp_placeholder' => 'host.example.com',
'port_label' => 'Port',
'add' => 'Hinzufügen',
// List
'no_checks_title' => 'Keine Prüfungen',
'no_checks' => 'Noch keine überwachten Dienste. Füge oben den ersten hinzu.',
'status_up' => 'Erreichbar',
'status_down' => 'Ausfall',
'latency' => ':ms ms',
'delete' => 'Entfernen',
// Toasts
'added' => 'Prüfung hinzugefügt.',
'deleted' => 'Prüfung entfernt.',
// Delete confirm
'delete_heading' => 'Prüfung entfernen',
'delete_body' => ':target wird nicht mehr überwacht.',
];

View File

@ -28,6 +28,7 @@ return [
'nav_posture' => 'Sicherheitslage',
'nav_patch' => 'Updates',
'nav_certs' => 'Zertifikate',
'nav_health' => 'Verfügbarkeit',
'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version',
'nav_help' => 'Hilfe',

View File

@ -67,6 +67,8 @@ return [
'firewall.rule_add' => 'Firewall rule added',
'firewall.rule_delete' => 'Firewall rule deleted',
'group.create' => 'Server group created',
'health.check_add' => 'Uptime check added',
'health.check_delete' => 'Uptime check removed',
'group.update' => 'Server group changed',
'group.delete' => 'Server group deleted',
'group.members' => 'Group membership changed',

38
lang/en/health.php Normal file
View File

@ -0,0 +1,38 @@
<?php
return [
'eyebrow' => 'Operations',
'title' => 'Uptime',
'subtitle' => 'HTTP/TCP reachability of monitored services',
'scan_hint' => 'Checking services …',
'up_of' => ':up/:total up',
// Add
'new_check' => 'New check',
'label_label' => 'Label',
'type_label' => 'Type',
'type_http' => 'HTTP',
'type_tcp' => 'TCP',
'target_label' => 'Target',
'target_http_placeholder' => 'https://service.example.com',
'target_tcp_placeholder' => 'host.example.com',
'port_label' => 'Port',
'add' => 'Add',
// List
'no_checks_title' => 'No checks',
'no_checks' => 'No monitored services yet. Add the first one above.',
'status_up' => 'Up',
'status_down' => 'Down',
'latency' => ':ms ms',
'delete' => 'Remove',
// Toasts
'added' => 'Check added.',
'deleted' => 'Check removed.',
// Delete confirm
'delete_heading' => 'Remove check',
'delete_body' => ':target will no longer be monitored.',
];

View File

@ -28,6 +28,7 @@ return [
'nav_posture' => 'Security posture',
'nav_patch' => 'Updates',
'nav_certs' => 'Certificates',
'nav_health' => 'Uptime',
'nav_threats' => 'Threats',
'nav_versions' => 'Version',
'nav_help' => 'Help',

View File

@ -64,6 +64,7 @@
<x-nav-item icon="shield-check" href="/posture" :active="request()->is('posture*')">{{ __('shell.nav_posture') }}</x-nav-item>
<x-nav-item icon="rotate" href="/patch" :active="request()->is('patch*')">{{ __('shell.nav_patch') }}</x-nav-item>
<x-nav-item icon="lock" href="/certs" :active="request()->is('certs*')">{{ __('shell.nav_certs') }}</x-nav-item>
<x-nav-item icon="activity" href="/health" :active="request()->is('health*')">{{ __('shell.nav_health') }}</x-nav-item>
@endcan
@can('manage-panel')
<x-nav-item icon="alert" href="/alerts" :active="request()->is('alerts*')" :badge="$alertCount > 0 ? $alertCount : null" :badge-title="$alertCount > 0 ? __('alerts.incidents_title') : null">{{ __('shell.nav_alerts') }}</x-nav-item>

View File

@ -0,0 +1,82 @@
@php
$field = 'h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
@endphp
<div class="space-y-5" @if (! $ready) wire:init="scan" @endif>
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('health.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('health.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('health.subtitle') }}</p>
</div>
@if ($ready && $total > 0)
<x-status-pill :status="$up === $total ? 'online' : 'offline'">{{ __('health.up_of', ['up' => $up, 'total' => $total]) }}</x-status-pill>
@endif
</div>
{{-- Add check --}}
<x-panel :title="__('health.new_check')">
<form wire:submit="addCheck" class="grid gap-3 sm:grid-cols-[minmax(0,10rem)_7rem_minmax(0,1fr)_6rem_auto] sm:items-end">
<div>
<label class="{{ $label }}">{{ __('health.label_label') }}</label>
<input wire:model="label" type="text" maxlength="60" class="{{ $field }}" />
</div>
<div>
<label class="{{ $label }}">{{ __('health.type_label') }}</label>
<select wire:model.live="type" class="{{ $field }}">
<option value="http">{{ __('health.type_http') }}</option>
<option value="tcp">{{ __('health.type_tcp') }}</option>
</select>
</div>
<div>
<label class="{{ $label }}">{{ __('health.target_label') }}</label>
<input wire:model="target" type="text" maxlength="2048" class="{{ $field }} font-mono"
placeholder="{{ $type === 'tcp' ? __('health.target_tcp_placeholder') : __('health.target_http_placeholder') }}" />
@error('target') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div @class(['invisible' => $type !== 'tcp'])>
<label class="{{ $label }}">{{ __('health.port_label') }}</label>
<input wire:model="port" type="number" min="1" max="65535" class="{{ $field }} font-mono" />
@error('port') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('health.add') }}</x-btn>
</form>
</x-panel>
{{-- Checks --}}
@if ($checks->isEmpty())
<x-panel>
<div class="py-8 text-center">
<p class="font-display text-sm font-semibold text-ink">{{ __('health.no_checks_title') }}</p>
<p class="mt-1 max-w-sm text-xs text-ink-3">{{ __('health.no_checks') }}</p>
</div>
</x-panel>
@else
<x-panel :padded="false">
<div class="divide-y divide-line">
@foreach ($checks as $check)
@php($r = $results[$check->uuid] ?? null)
<div wire:key="hc-{{ $check->uuid }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$r ? ($r['ok'] ? 'online' : 'offline') : 'pending'" :ping="$r && $r['ok']" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-ink">{{ $check->label ?: $check->target }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">
<span class="uppercase">{{ $check->type }}</span> · {{ $check->target }}@if ($check->type === 'tcp'):{{ $check->port }}@endif
@if ($r) · {{ $r['detail'] }} @endif
</p>
</div>
@if ($r && ! is_null($r['latency']))
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('health.latency', ['ms' => $r['latency']]) }}</span>
@endif
@if ($r)
<x-status-pill :status="$r['ok'] ? 'online' : 'offline'">{{ $r['ok'] ? __('health.status_up') : __('health.status_down') }}</x-status-pill>
@endif
<x-modal-trigger variant="danger-soft" action="confirmDeleteCheck('{{ $check->uuid }}')">{{ __('health.delete') }}</x-modal-trigger>
</div>
@endforeach
</div>
</x-panel>
@endif
</div>

View File

@ -13,6 +13,7 @@ use App\Livewire\Commands;
use App\Livewire\Dashboard;
use App\Livewire\Docker;
use App\Livewire\Files;
use App\Livewire\Health;
use App\Livewire\Help;
use App\Livewire\Patch;
use App\Livewire\Posture;
@ -243,6 +244,7 @@ Route::middleware('auth')->group(function () {
Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture');
Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch');
Route::get('/certs', Certs\Index::class)->middleware('can:operate')->name('certs');
Route::get('/health', Health\Index::class)->middleware('can:operate')->name('health');
Route::get('/help', Help\Index::class)->name('help');
Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard');
Route::get('/terminal', Terminal\Index::class)->name('terminal');

View File

@ -0,0 +1,126 @@
<?php
namespace Tests\Feature;
use App\Livewire\Health\Index;
use App\Models\HealthCheck;
use App\Models\User;
use App\Services\HealthService;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class HealthComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
public function test_viewer_cannot_open_health(): void
{
$this->actingAs($this->viewer())->get('/health')->assertForbidden();
}
public function test_operator_can_open_health(): void
{
$this->actingAs($this->operator())->get('/health')->assertOk();
}
public function test_operator_adds_an_http_check_and_it_is_audited(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('type', 'http')
->set('target', 'https://svc.example.com/health')
->call('addCheck')
->assertHasNoErrors();
$this->assertDatabaseHas('health_checks', ['type' => 'http', 'target' => 'https://svc.example.com/health', 'port' => null]);
$this->assertDatabaseHas('audit_events', ['action' => 'health.check_add']);
}
public function test_http_check_rejects_a_non_url(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('type', 'http')
->set('target', 'not a url; rm -rf')
->call('addCheck')
->assertHasErrors('target');
$this->assertDatabaseCount('health_checks', 0);
}
public function test_tcp_check_requires_a_host_and_port(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('type', 'tcp')
->set('target', 'db.example.com')
->set('port', 5432)
->call('addCheck')
->assertHasNoErrors();
$this->assertDatabaseHas('health_checks', ['type' => 'tcp', 'target' => 'db.example.com', 'port' => 5432]);
}
public function test_tcp_check_rejects_a_missing_port(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('type', 'tcp')
->set('target', 'db.example.com')
->set('port', null)
->call('addCheck')
->assertHasErrors('port');
}
public function test_scan_probes_each_check(): void
{
$this->actingAs($this->operator());
HealthCheck::create(['type' => 'http', 'target' => 'https://svc.example.com']);
$health = Mockery::mock(HealthService::class);
$health->shouldReceive('probe')->once()->andReturn(['ok' => true, 'latency' => 42, 'detail' => 'HTTP 200']);
app()->instance(HealthService::class, $health);
Livewire::test(Index::class)
->call('scan')
->assertOk()
->assertSet('ready', true)
->assertSee(__('health.status_up'))
->assertViewHas('up', 1);
}
public function test_operator_deletes_a_check_via_a_confirmed_token(): void
{
$this->actingAs($this->operator());
$check = HealthCheck::create(['type' => 'http', 'target' => 'https://gone.example.com']);
$token = ConfirmToken::issue('healthCheckDeleted', ['uuid' => $check->uuid], 'health.check_delete', $check->target, null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('deleteCheck', $token)->assertOk();
$this->assertDatabaseMissing('health_checks', ['id' => $check->id]);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\HealthCheck;
use App\Services\HealthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class HealthServiceTest extends TestCase
{
use RefreshDatabase;
private function httpCheck(string $url = 'https://svc.example.com/health'): HealthCheck
{
return HealthCheck::create(['type' => 'http', 'target' => $url]);
}
public function test_http_2xx_is_up(): void
{
Http::fake(['*' => Http::response('ok', 200)]);
$r = (new HealthService)->probe($this->httpCheck());
$this->assertTrue($r['ok']);
$this->assertSame('HTTP 200', $r['detail']);
$this->assertNotNull($r['latency']);
}
public function test_http_5xx_is_down_but_answered(): void
{
Http::fake(['*' => Http::response('boom', 500)]);
$r = (new HealthService)->probe($this->httpCheck());
$this->assertFalse($r['ok']);
$this->assertSame('HTTP 500', $r['detail']);
}
public function test_http_connection_error_is_down(): void
{
Http::fake(fn () => throw new ConnectionException('Could not resolve host'));
$r = (new HealthService)->probe($this->httpCheck());
$this->assertFalse($r['ok']);
$this->assertNull($r['latency']);
$this->assertStringContainsString('resolve host', $r['detail']);
}
public function test_tcp_to_a_refused_port_is_down(): void
{
$check = HealthCheck::create(['type' => 'tcp', 'target' => '127.0.0.1', 'port' => 1]);
$r = (new HealthService)->probe($check, 1);
$this->assertFalse($r['ok']);
$this->assertArrayHasKey('detail', $r);
}
}