diff --git a/app/Livewire/Health/Index.php b/app/Livewire/Health/Index.php
new file mode 100644
index 0000000..7673956
--- /dev/null
+++ b/app/Livewire/Health/Index.php
@@ -0,0 +1,158 @@
+ 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());
+ }
+}
diff --git a/app/Models/HealthCheck.php b/app/Models/HealthCheck.php
new file mode 100644
index 0000000..42916d8
--- /dev/null
+++ b/app/Models/HealthCheck.php
@@ -0,0 +1,26 @@
+ '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';
+ }
+}
diff --git a/app/Services/HealthService.php b/app/Services/HealthService.php
new file mode 100644
index 0000000..1645329
--- /dev/null
+++ b/app/Services/HealthService.php
@@ -0,0 +1,66 @@
+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;
+ }
+}
diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php
index 44e0688..579b969 100644
--- a/app/Support/Confirm/ConfirmToken.php
+++ b/app/Support/Confirm/ConfirmToken.php
@@ -72,6 +72,7 @@ class ConfirmToken
'runbookDeleted',
'patchApply',
'certEndpointDeleted',
+ 'healthCheckDeleted',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
diff --git a/database/migrations/2026_07_05_100006_create_health_checks_table.php b/database/migrations/2026_07_05_100006_create_health_checks_table.php
new file mode 100644
index 0000000..146f753
--- /dev/null
+++ b/database/migrations/2026_07_05_100006_create_health_checks_table.php
@@ -0,0 +1,26 @@
+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');
+ }
+};
diff --git a/lang/de/audit.php b/lang/de/audit.php
index daf7651..ed43b19 100644
--- a/lang/de/audit.php
+++ b/lang/de/audit.php
@@ -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',
diff --git a/lang/de/health.php b/lang/de/health.php
new file mode 100644
index 0000000..a641c4e
--- /dev/null
+++ b/lang/de/health.php
@@ -0,0 +1,38 @@
+ '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.',
+];
diff --git a/lang/de/shell.php b/lang/de/shell.php
index f53f9a8..69de19c 100644
--- a/lang/de/shell.php
+++ b/lang/de/shell.php
@@ -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',
diff --git a/lang/en/audit.php b/lang/en/audit.php
index 1b8e78a..863399e 100644
--- a/lang/en/audit.php
+++ b/lang/en/audit.php
@@ -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',
diff --git a/lang/en/health.php b/lang/en/health.php
new file mode 100644
index 0000000..0806f63
--- /dev/null
+++ b/lang/en/health.php
@@ -0,0 +1,38 @@
+ '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.',
+];
diff --git a/lang/en/shell.php b/lang/en/shell.php
index fd1ac91..42d7588 100644
--- a/lang/en/shell.php
+++ b/lang/en/shell.php
@@ -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',
diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php
index 4f20579..fdb4258 100644
--- a/resources/views/components/sidebar.blade.php
+++ b/resources/views/components/sidebar.blade.php
@@ -64,6 +64,7 @@
{{ __('health.eyebrow') }}
+{{ __('health.subtitle') }}
+{{ __('health.no_checks_title') }}
+{{ __('health.no_checks') }}
+{{ $check->label ?: $check->target }}
++ {{ $check->type }} · {{ $check->target }}@if ($check->type === 'tcp'):{{ $check->port }}@endif + @if ($r) · {{ $r['detail'] }} @endif +
+