diff --git a/app/Services/Monitoring/SystemStatus.php b/app/Services/Monitoring/SystemStatus.php new file mode 100644 index 00000000..e0690dd4 --- /dev/null +++ b/app/Services/Monitoring/SystemStatus.php @@ -0,0 +1,170 @@ +} + */ + public function health(): array + { + $checks = $this->probe->checks(); + + return ['status' => HealthProbe::worstStatus($checks), 'checks' => $checks]; + } + + /** + * @return array{version: string, environment: string, debug: bool, php: string, laravel: string, deployed_at: ?Carbon} + */ + public function application(): array + { + $release = base_path('RELEASE'); + + return [ + 'version' => (string) (config('app.version') ?? '—'), + 'environment' => app()->environment(), + 'debug' => (bool) config('app.debug'), + 'php' => PHP_VERSION, + 'laravel' => app()->version(), + 'deployed_at' => is_file($release) ? Carbon::createFromTimestamp((int) filemtime($release))->timezone(self::DisplayTimezone) : null, + ]; + } + + /** + * Geplante Aufgaben mit nächstem Lauf (über `schedule:list`, das die Konsolen-Routen lädt). + * + * @return list + */ + public function scheduledTasks(): array + { + try { + Artisan::call('schedule:list', ['--json' => true]); + $tasks = json_decode(Artisan::output(), true); + } catch (Throwable) { + return []; + } + + return collect(is_array($tasks) ? $tasks : []) + ->map(fn (array $task): array => [ + 'command' => trim((string) preg_replace('/^.*?artisan\s+/', '', (string) ($task['command'] ?? ''))), + 'expression' => (string) ($task['expression'] ?? ''), + 'next' => filled($task['next_due_date'] ?? null) ? Carbon::parse($task['next_due_date'])->timezone(self::DisplayTimezone) : null, + 'description' => (string) ($task['description'] ?? ''), + ]) + ->values() + ->all(); + } + + /** + * @return array{count: int, latest: list} + */ + public function failedJobs(int $limit = 5): array + { + try { + $table = DB::table((string) config('queue.failed.table', 'failed_jobs')); + + return [ + 'count' => $table->count(), + 'latest' => $table->orderByDesc('failed_at')->limit($limit)->get() + ->map(fn (object $job): array => [ + 'job' => (string) (json_decode((string) $job->payload, true)['displayName'] ?? 'Job'), + 'queue' => (string) $job->queue, + 'failed_at' => Carbon::parse($job->failed_at)->timezone(self::DisplayTimezone), + 'error' => ErrorReportScrubber::scrubText(Str::limit(strtok((string) $job->exception, "\n") ?: '', 180)), + ]) + ->all(), + ]; + } catch (Throwable) { + return ['count' => 0, 'latest' => []]; + } + } + + /** + * Letzte Einträge ab einer Stufe aus dem jüngsten Log einer Art (`laravel`, `monitoring`). + * + * @param list $levels + * @return list + */ + public function recentLogEntries(string $prefix, array $levels, int $limit = 10): array + { + $files = glob(storage_path("logs/{$prefix}*.log")) ?: []; + usort($files, fn (string $a, string $b): int => filemtime($b) <=> filemtime($a)); + $file = $files[0] ?? null; + + if ($file === null) { + return []; + } + + $handle = @fopen($file, 'rb'); + + if ($handle === false) { + return []; + } + + $size = (int) filesize($file); + fseek($handle, max(0, $size - self::LogTailBytes)); + $tail = (string) stream_get_contents($handle); + fclose($handle); + + $entries = []; + + foreach (preg_split('/\R/', $tail) ?: [] as $line) { + if (preg_match(self::LogLinePattern, $line, $match) !== 1 || ! in_array(strtoupper($match[2]), $levels, true)) { + continue; + } + + $message = (string) preg_replace('/\s*\{"(exception|userId)".*$/s', '', $match[3]); + + $entries[] = [ + 'at' => Carbon::parse($match[1])->timezone(self::DisplayTimezone), + 'level' => strtolower($match[2]), + 'message' => ErrorReportScrubber::scrubText(Str::limit($message, 220)), + ]; + } + + return array_slice(array_reverse($entries), 0, $limit); + } + + /** + * Links zu den Diensten außerhalb der App, soweit konfiguriert. + * + * @return array + */ + public function links(): array + { + $links = []; + $dsn = (string) config('sentry.dsn'); + + if (preg_match('#^https?://[^@]+@([^/]+)/(\d+)#', $dsn, $match) === 1) { + $links['GlitchTip'] = "https://{$match[1]}/"; + } + + if (filled(config('monitoring.status_url'))) { + $links['Uptime Kuma'] = (string) config('monitoring.status_url'); + } + + return $links; + } +} diff --git a/config/monitoring.php b/config/monitoring.php index 715530af..eaef1aab 100644 --- a/config/monitoring.php +++ b/config/monitoring.php @@ -72,4 +72,16 @@ return [ 'heartbeat_critical_minutes' => 10, ], + /* + |-------------------------------------------------------------------------- + | Monitoring-Seite im Admin + |-------------------------------------------------------------------------- + | + | Link zur Statusseite von Uptime Kuma (optional). GlitchTip wird aus der + | DSN abgeleitet. + | + */ + + 'status_url' => env('MONITORING_STATUS_URL'), + ]; diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index ac1f3c3d..3dd328b5 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -117,48 +117,6 @@ @endhasrole @hasrole('Super-Admin|Admin') - - - {{ __('Benutzer') }} - - - {{ __('Partner einladen') }} - - {{ __('Registrierungscodes') }} - {{ __('Permissions') }} - - - - - - - - {{ __('Produkt-Verwaltung') }} - - - {{ __('Alle Produkte') }} - - - - - {{ __('Hub-Verwaltung') }} - - - + + + + {{ __('Produkt-Verwaltung') }} + + + {{ __('Alle Produkte') }} + + + + + {{ __('Hub-Verwaltung') }} + + + + + + {{ __('Benutzer') }} + + + {{ __('Partner einladen') }} + + {{ __('Registrierungscodes') }} + {{ __('Permissions') }} + + + + @endhasrole @hasrole('Super-Admin') @@ -214,6 +214,15 @@ :current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }} @endhasrole + + {{-- Monitoring ganz unten in der Navigation (09.10.2026) --}} + @hasrole('Super-Admin|Admin') + + {{ __('Systemstatus') }} + + + @endhasrole diff --git a/resources/views/livewire/admin/monitoring.blade.php b/resources/views/livewire/admin/monitoring.blade.php new file mode 100644 index 00000000..99f730a4 --- /dev/null +++ b/resources/views/livewire/admin/monitoring.blade.php @@ -0,0 +1,196 @@ +user()?->hasAnyRole(['Super-Admin', 'Admin']), 403); +}); + +$health = computed(fn () => app(SystemStatus::class)->health()); +$application = computed(fn () => app(SystemStatus::class)->application()); +$tasks = computed(fn () => app(SystemStatus::class)->scheduledTasks()); +$failedJobs = computed(fn () => app(SystemStatus::class)->failedJobs()); +$errors = computed(fn () => app(SystemStatus::class)->recentLogEntries('laravel', ['ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY'])); +$alerts = computed(fn () => app(SystemStatus::class)->recentLogEntries('monitoring', ['CRITICAL', 'ERROR', 'WARNING'])); +$links = computed(fn () => app(SystemStatus::class)->links()); + +$refresh = function () { + unset($this->health, $this->tasks, $this->failedJobs, $this->errors, $this->alerts); +}; + +$sendTestAlert = function () { + OperationalAlert::send('Test-Alarm aus dem Admin (Monitoring)', ['ausgeloest_von' => auth()->id()]); + unset($this->alerts); + + Flux::toast(variant: 'success', heading: 'Test-Alarm gesendet', text: 'Mail und Push gehen an die konfigurierten Empfänger; der Eintrag steht unten unter „Alarme“.'); +}; + +?> + +@php + $statusColor = fn (string $status) => match ($status) { + HealthProbe::StatusOk => 'green', + HealthProbe::StatusWarn => 'amber', + default => 'red', + }; + $statusLabel = fn (string $status) => match ($status) { + HealthProbe::StatusOk => 'OK', + HealthProbe::StatusWarn => 'Warnung', + default => 'Kritisch', + }; +@endphp + +
+
+
+ Monitoring + Zustand von Website und Server – dieselben Prüfungen, die Uptime Kuma über /health abfragt. +
+
+ @foreach ($this->links as $label => $url) + {{ $label }} + @endforeach + Test-Alarm + Neu prüfen +
+
+ + {{-- Gesamtstatus und Prüfungen --}} +
+
+ Prüfungen + {{ $statusLabel($this->health['status']) }} +
+
+ @foreach ($this->health['checks'] as $check) + +
+ {{ $check['label'] }} + {{ $statusLabel($check['status']) }} +
+ {{ $check['detail'] }} +
+ @endforeach +
+
+ +
+ {{-- Anwendung --}} + + Anwendung +
+
Version
+
{{ $this->application['version'] }}
+
Ausgeliefert
+
{{ $this->application['deployed_at']?->format('d.m.Y H:i') ?? '—' }}
+
Umgebung
+
{{ $this->application['environment'] }}
+
Debug-Modus
+
+ @if ($this->application['debug'] && app()->environment('production')) + an – auf Live ausschalten + @else + {{ $this->application['debug'] ? 'an' : 'aus' }} + @endif +
+
PHP / Laravel
+
{{ $this->application['php'] }} / {{ $this->application['laravel'] }}
+
+
+ + {{-- Geplante Aufgaben --}} + + Geplante Aufgaben + @if ($this->tasks === []) + Keine geplanten Aufgaben gefunden. + @else +
+ @foreach ($this->tasks as $task) +
+
+
{{ $task['command'] }}
+ @if ($task['description'] !== '') +
{{ $task['description'] }}
+ @endif +
+
+
{{ $task['expression'] }}
+
nächster Lauf {{ $task['next']?->format('d.m. H:i') ?? '—' }}
+
+
+ @endforeach +
+ @endif +
+
+ + {{-- Fehler aus dem Log --}} + +
+ Letzte Fehler + Aus storage/logs – Details und Häufigkeit in GlitchTip +
+ @if ($this->errors === []) + Keine Fehler im aktuellen Log. + @else +
+ @foreach ($this->errors as $entry) +
+
{{ $entry['at']->format('d.m. H:i:s') }}
+ {{ $entry['level'] }} +
{{ $entry['message'] }}
+
+ @endforeach +
+ @endif +
+ +
+ {{-- Alarme --}} + + Alarme + @if ($this->alerts === []) + Keine Alarme im aktuellen Monitoring-Log. + @else +
+ @foreach ($this->alerts as $entry) +
+
{{ $entry['at']->format('d.m. H:i:s') }}
+
{{ \Illuminate\Support\Str::after($entry['message'], '[monitoring] ') }}
+
+ @endforeach +
+ @endif +
+ + {{-- Fehlgeschlagene Jobs --}} + +
+ Fehlgeschlagene Jobs + {{ $this->failedJobs['count'] }} +
+ @if ($this->failedJobs['latest'] === []) + Keine fehlgeschlagenen Jobs. + @else +
+ @foreach ($this->failedJobs['latest'] as $job) +
+
+ {{ $job['job'] }} + {{ $job['failed_at']->format('d.m. H:i') }} +
+
{{ $job['error'] }}
+
+ @endforeach +
+ @endif +
+
+
diff --git a/routes/admin.php b/routes/admin.php index dcf639d8..8365cb6f 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -94,6 +94,9 @@ Route::middleware(['auth', 'partner.setup'])->group(function () { Volt::route('admin/flux-cms/brands', 'admin.cms.brands-index')->name('cms.brands.index'); Volt::route('admin/flux-cms/references', 'admin.cms.references-index')->name('cms.references.index'); + // Monitoring (Systemstatus, nur Admin) + Volt::route('admin/monitoring', 'admin.monitoring')->name('admin.monitoring'); + // Documentation Volt::route('admin/documentation', 'admin.documentation')->name('admin.documentation'); Route::get('admin/documentation/download', function () { diff --git a/tests/Feature/Monitoring/AdminMonitoringPageTest.php b/tests/Feature/Monitoring/AdminMonitoringPageTest.php new file mode 100644 index 00000000..d354f107 --- /dev/null +++ b/tests/Feature/Monitoring/AdminMonitoringPageTest.php @@ -0,0 +1,93 @@ + 'Admin']); + Role::create(['name' => 'Retailer']); + config()->set('monitoring.health.disk_min_free_percent', 0); +}); + +afterEach(function () { + @unlink(HealthProbe::heartbeatPath()); + @unlink(storage_path('logs/laravel-monitoring-test.log')); +}); + +function monitoringAdmin(): User +{ + return tap(User::factory()->create(), fn (User $user) => $user->assignRole('Admin')); +} + +it('shows the checks, the version and the scheduled tasks to admins', function () { + config()->set('app.version', 'v2.0.6'); + app(HealthProbe::class)->recordHeartbeat(); + + $this->actingAs(monitoringAdmin()); + + Volt::test('admin.monitoring') + ->assertSee('Prüfungen') + ->assertSee('Datenbank') + ->assertSee('Scheduler') + ->assertSee('v2.0.6') + ->assertSee('monitoring:heartbeat') + ->assertSee('cabinet:reset-overrides'); +}); + +it('is closed to partners without admin role', function () { + $partner = tap(User::factory()->create(), fn (User $user) => $user->assignRole('Retailer')); + + $this->actingAs($partner); + + Volt::test('admin.monitoring')->assertForbidden(); +}); + +it('sends a test alert from the page', function () { + Http::fake(); + config()->set('monitoring.alerts.enabled', true); + config()->set('monitoring.alerts.ntfy', ['url' => 'https://push.example.com', 'topic' => 'b2in-ops', 'token' => null, 'priority' => 4]); + + $this->actingAs(monitoringAdmin()); + + Volt::test('admin.monitoring')->call('sendTestAlert'); + + Http::assertSent(fn ($request) => str_contains((string) $request['message'], 'Test-Alarm aus dem Admin')); +}); + +it('reads the latest errors from the log without email addresses', function () { + file_put_contents(storage_path('logs/laravel-monitoring-test.log'), implode("\n", [ + '[2026-10-09 08:00:00] production.INFO: Nur Info', + '[2026-10-09 09:00:00] production.ERROR: Mail an kunde@example.com fehlgeschlagen {"exception":"[object] ..."}', + '#0 /home/stacktrace', + ])."\n"); + touch(storage_path('logs/laravel-monitoring-test.log'), time() + 60); + + $entries = app(SystemStatus::class)->recentLogEntries('laravel', ['ERROR']); + + expect($entries)->toHaveCount(1) + ->and($entries[0]['message'])->toBe('Mail an [email] fehlgeschlagen') + ->and($entries[0]['at']->format('H:i'))->toBe('11:00'); +}); + +it('places monitoring at the very end of the admin navigation, after cms, cabinet, products, regions and admin', function () { + $this->actingAs(monitoringAdmin()); + + $html = $this->get(route('admin.monitoring'))->assertSuccessful()->getContent(); + $offset = strpos($html, '>CMS<'); + expect($offset)->not->toBeFalse(); + + foreach (['>Cabinet<', '>Produkte<', '>Regionen<', '>Admin<', '>Monitoring<'] as $heading) { + $next = strpos($html, $heading, $offset); + expect($next)->not->toBeFalse("{$heading} fehlt oder steht zu weit oben"); + $offset = $next; + } + + expect(strpos($html, '>Superadmin<', $offset))->toBeFalse(); +});