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
parent
3cab72bf46
commit
65b92bc65c
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ class ConfirmToken
|
|||
'runbookDeleted',
|
||||
'patchApply',
|
||||
'certEndpointDeleted',
|
||||
'healthCheckDeleted',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue