Admin: Monitoring-Seite (Systemstatus) und Navigation neu sortiert
- /admin/monitoring (nur Admin): Prüfungen wie /health, Version und Deploy-Zeit, geplante Aufgaben mit nächstem Lauf, letzte Fehler aus dem Log (ohne E-Mail-Adressen), Alarme, fehlgeschlagene Jobs, Links zu GlitchTip/Uptime Kuma (MONITORING_STATUS_URL), Test-Alarm - Navigation: CMS, Cabinet, Produkte, Regionen, Admin, ganz unten Monitoring Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8ddc85422e
commit
061f004013
6 changed files with 525 additions and 42 deletions
170
app/Services/Monitoring/SystemStatus.php
Normal file
170
app/Services/Monitoring/SystemStatus.php
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Monitoring;
|
||||
|
||||
use App\Support\Monitoring\ErrorReportScrubber;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daten für die Monitoring-Seite im Admin (09.10.2026): dieselben Prüfungen wie `/health`, dazu
|
||||
* Version, geplante Aufgaben, fehlgeschlagene Jobs und die letzten Fehler und Alarme aus den
|
||||
* Logs. Liest nur, ändert nichts; Logs nur das Ende der Datei, damit die Seite schnell bleibt.
|
||||
*/
|
||||
class SystemStatus
|
||||
{
|
||||
/** Zeitzone für die Anzeige – die App rechnet in UTC. */
|
||||
public const DisplayTimezone = 'Europe/Berlin';
|
||||
|
||||
private const LogTailBytes = 262144;
|
||||
|
||||
private const LogLinePattern = '/^\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.*)$/';
|
||||
|
||||
public function __construct(private readonly HealthProbe $probe) {}
|
||||
|
||||
/**
|
||||
* @return array{status: string, checks: list<array{key: string, label: string, status: string, detail: string}>}
|
||||
*/
|
||||
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<array{command: string, expression: string, next: ?Carbon, description: string}>
|
||||
*/
|
||||
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<array{job: string, queue: string, failed_at: Carbon, error: string}>}
|
||||
*/
|
||||
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<string> $levels
|
||||
* @return list<array{at: Carbon, level: string, message: string}>
|
||||
*/
|
||||
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<string, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -117,48 +117,6 @@
|
|||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Admin')" class="grid mb-4">
|
||||
<flux:navlist.group expandable
|
||||
:expanded="request()->routeIs(['admin.users', 'admin.users.permissions', 'admin.partners.invite', 'admin.partners.registration-codes'])"
|
||||
heading="Partner" class="grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')"
|
||||
:current="request()->routeIs('admin.users')" wire:navigate>{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')"
|
||||
:current="request()->routeIs('admin.partners.invite')" wire:navigate>
|
||||
{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('admin.partners.registration-codes')"
|
||||
:current="request()->routeIs('admin.partners.registration-codes')" wire:navigate>
|
||||
{{ __('Registrierungscodes') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.users.permissions')"
|
||||
:current="request()->routeIs('admin.users.permissions')" wire:navigate>{{ __('Permissions') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs('testing')" heading="Testing"
|
||||
class="hidden lg:grid mt-2">
|
||||
<flux:navlist.item icon="user-group" :href="route('testing.landing')"
|
||||
:current="request()->routeIs('testing.landing')" wire:navigate>{{ __('Register') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="clipboard-document-check" :href="route('admin.products.index')"
|
||||
:current="request()->routeIs('admin.products.*')" wire:navigate>
|
||||
{{ __('Produkt-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')"
|
||||
:current="request()->routeIs('products.index')" wire:navigate>
|
||||
{{ __('Alle Produkte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Regionen')" class="grid mb-4">
|
||||
<flux:navlist.item icon="map" :href="route('admin.hubs.index')"
|
||||
:current="request()->routeIs('admin.hubs.*')" wire:navigate>{{ __('Hub-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('CMS')" class="grid mb-4">
|
||||
<flux:navlist.group expandable
|
||||
:expanded="request()->routeIs(['cms.dashboard', 'cms.content.index', 'cms.references.index', 'cms.brands.index', 'cms.articles.index', 'cms.media.index'])"
|
||||
|
|
@ -206,6 +164,48 @@
|
|||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Produkte')" class="grid mb-4">
|
||||
<flux:navlist.item icon="clipboard-document-check" :href="route('admin.products.index')"
|
||||
:current="request()->routeIs('admin.products.*')" wire:navigate>
|
||||
{{ __('Produkt-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="cube" :href="route('products.index')"
|
||||
:current="request()->routeIs('products.index')" wire:navigate>
|
||||
{{ __('Alle Produkte') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Regionen')" class="grid mb-4">
|
||||
<flux:navlist.item icon="map" :href="route('admin.hubs.index')"
|
||||
:current="request()->routeIs('admin.hubs.*')" wire:navigate>{{ __('Hub-Verwaltung') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
|
||||
<flux:navlist.group :heading="__('Admin')" class="grid mb-4">
|
||||
<flux:navlist.group expandable
|
||||
:expanded="request()->routeIs(['admin.users', 'admin.users.permissions', 'admin.partners.invite', 'admin.partners.registration-codes'])"
|
||||
heading="Partner" class="grid">
|
||||
<flux:navlist.item icon="user-group" :href="route('admin.users')"
|
||||
:current="request()->routeIs('admin.users')" wire:navigate>{{ __('Benutzer') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item icon="envelope" :href="route('admin.partners.invite')"
|
||||
:current="request()->routeIs('admin.partners.invite')" wire:navigate>
|
||||
{{ __('Partner einladen') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="key" :href="route('admin.partners.registration-codes')"
|
||||
:current="request()->routeIs('admin.partners.registration-codes')" wire:navigate>
|
||||
{{ __('Registrierungscodes') }}</flux:navlist.item>
|
||||
<flux:navlist.item icon="shield-check" :href="route('admin.users.permissions')"
|
||||
:current="request()->routeIs('admin.users.permissions')" wire:navigate>{{ __('Permissions') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
<flux:navlist.group expandable :expanded="request()->routeIs('testing')" heading="Testing"
|
||||
class="hidden lg:grid mt-2">
|
||||
<flux:navlist.item icon="user-group" :href="route('testing.landing')"
|
||||
:current="request()->routeIs('testing.landing')" wire:navigate>{{ __('Register') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
|
||||
@hasrole('Super-Admin')
|
||||
|
|
@ -214,6 +214,15 @@
|
|||
:current="request()->routeIs('dashboard')" wire:navigate>{{ __('Dashboard') }}</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
|
||||
{{-- Monitoring ganz unten in der Navigation (09.10.2026) --}}
|
||||
@hasrole('Super-Admin|Admin')
|
||||
<flux:navlist.group :heading="__('Monitoring')" class="grid mb-4">
|
||||
<flux:navlist.item icon="heart" :href="route('admin.monitoring')"
|
||||
:current="request()->routeIs('admin.monitoring')" wire:navigate>{{ __('Systemstatus') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endhasrole
|
||||
</flux:navlist>
|
||||
|
||||
<flux:spacer />
|
||||
|
|
|
|||
196
resources/views/livewire/admin/monitoring.blade.php
Normal file
196
resources/views/livewire/admin/monitoring.blade.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use App\Services\Monitoring\SystemStatus;
|
||||
use App\Support\OperationalAlert;
|
||||
use Flux\Flux;
|
||||
use function Livewire\Volt\{computed, layout, mount, title};
|
||||
|
||||
layout('components.layouts.app');
|
||||
title('Monitoring');
|
||||
|
||||
mount(function () {
|
||||
abort_unless(auth()->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
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<flux:heading size="xl">Monitoring</flux:heading>
|
||||
<flux:text class="mt-1">Zustand von Website und Server – dieselben Prüfungen, die Uptime Kuma über /health abfragt.</flux:text>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($this->links as $label => $url)
|
||||
<flux:button size="sm" variant="ghost" icon-trailing="arrow-top-right-on-square" :href="$url" target="_blank">{{ $label }}</flux:button>
|
||||
@endforeach
|
||||
<flux:button size="sm" icon="bell-alert" wire:click="sendTestAlert" wire:confirm="Test-Alarm per Mail und Push senden?">Test-Alarm</flux:button>
|
||||
<flux:button size="sm" variant="primary" icon="arrow-path" wire:click="refresh">Neu prüfen</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Gesamtstatus und Prüfungen --}}
|
||||
<div data-monitoring-health>
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<flux:heading size="lg">Prüfungen</flux:heading>
|
||||
<flux:badge :color="$statusColor($this->health['status'])">{{ $statusLabel($this->health['status']) }}</flux:badge>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
@foreach ($this->health['checks'] as $check)
|
||||
<flux:card wire:key="check-{{ $check['key'] }}" class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<flux:heading>{{ $check['label'] }}</flux:heading>
|
||||
<flux:badge size="sm" :color="$statusColor($check['status'])">{{ $statusLabel($check['status']) }}</flux:badge>
|
||||
</div>
|
||||
<flux:text size="sm">{{ $check['detail'] }}</flux:text>
|
||||
</flux:card>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{{-- Anwendung --}}
|
||||
<flux:card class="space-y-3">
|
||||
<flux:heading size="lg">Anwendung</flux:heading>
|
||||
<dl class="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-sm">
|
||||
<dt class="text-zinc-500">Version</dt>
|
||||
<dd class="font-medium">{{ $this->application['version'] }}</dd>
|
||||
<dt class="text-zinc-500">Ausgeliefert</dt>
|
||||
<dd>{{ $this->application['deployed_at']?->format('d.m.Y H:i') ?? '—' }}</dd>
|
||||
<dt class="text-zinc-500">Umgebung</dt>
|
||||
<dd>{{ $this->application['environment'] }}</dd>
|
||||
<dt class="text-zinc-500">Debug-Modus</dt>
|
||||
<dd>
|
||||
@if ($this->application['debug'] && app()->environment('production'))
|
||||
<flux:badge size="sm" color="red">an – auf Live ausschalten</flux:badge>
|
||||
@else
|
||||
{{ $this->application['debug'] ? 'an' : 'aus' }}
|
||||
@endif
|
||||
</dd>
|
||||
<dt class="text-zinc-500">PHP / Laravel</dt>
|
||||
<dd>{{ $this->application['php'] }} / {{ $this->application['laravel'] }}</dd>
|
||||
</dl>
|
||||
</flux:card>
|
||||
|
||||
{{-- Geplante Aufgaben --}}
|
||||
<flux:card class="space-y-3">
|
||||
<flux:heading size="lg">Geplante Aufgaben</flux:heading>
|
||||
@if ($this->tasks === [])
|
||||
<flux:text>Keine geplanten Aufgaben gefunden.</flux:text>
|
||||
@else
|
||||
<div class="divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
||||
@foreach ($this->tasks as $task)
|
||||
<div wire:key="task-{{ $loop->index }}" class="flex items-start justify-between gap-4 py-2">
|
||||
<div>
|
||||
<div class="font-medium">{{ $task['command'] }}</div>
|
||||
@if ($task['description'] !== '')
|
||||
<div class="text-zinc-500">{{ $task['description'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="shrink-0 text-right text-zinc-500">
|
||||
<div class="font-mono text-xs">{{ $task['expression'] }}</div>
|
||||
<div>nächster Lauf {{ $task['next']?->format('d.m. H:i') ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
|
||||
{{-- Fehler aus dem Log --}}
|
||||
<flux:card class="space-y-3" data-monitoring-errors>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<flux:heading size="lg">Letzte Fehler</flux:heading>
|
||||
<flux:text size="sm">Aus storage/logs – Details und Häufigkeit in GlitchTip</flux:text>
|
||||
</div>
|
||||
@if ($this->errors === [])
|
||||
<flux:text>Keine Fehler im aktuellen Log.</flux:text>
|
||||
@else
|
||||
<div class="divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
||||
@foreach ($this->errors as $entry)
|
||||
<div wire:key="error-{{ $loop->index }}" class="flex gap-4 py-2">
|
||||
<div class="w-28 shrink-0 text-zinc-500">{{ $entry['at']->format('d.m. H:i:s') }}</div>
|
||||
<flux:badge size="sm" color="red" class="h-fit shrink-0">{{ $entry['level'] }}</flux:badge>
|
||||
<div class="min-w-0 break-words">{{ $entry['message'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{{-- Alarme --}}
|
||||
<flux:card class="space-y-3" data-monitoring-alerts>
|
||||
<flux:heading size="lg">Alarme</flux:heading>
|
||||
@if ($this->alerts === [])
|
||||
<flux:text>Keine Alarme im aktuellen Monitoring-Log.</flux:text>
|
||||
@else
|
||||
<div class="divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
||||
@foreach ($this->alerts as $entry)
|
||||
<div wire:key="alert-{{ $loop->index }}" class="flex gap-4 py-2">
|
||||
<div class="w-28 shrink-0 text-zinc-500">{{ $entry['at']->format('d.m. H:i:s') }}</div>
|
||||
<div class="min-w-0 break-words">{{ \Illuminate\Support\Str::after($entry['message'], '[monitoring] ') }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
|
||||
{{-- Fehlgeschlagene Jobs --}}
|
||||
<flux:card class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading size="lg">Fehlgeschlagene Jobs</flux:heading>
|
||||
<flux:badge size="sm" :color="$this->failedJobs['count'] > 0 ? 'red' : 'zinc'">{{ $this->failedJobs['count'] }}</flux:badge>
|
||||
</div>
|
||||
@if ($this->failedJobs['latest'] === [])
|
||||
<flux:text>Keine fehlgeschlagenen Jobs.</flux:text>
|
||||
@else
|
||||
<div class="divide-y divide-zinc-200 text-sm dark:divide-zinc-700">
|
||||
@foreach ($this->failedJobs['latest'] as $job)
|
||||
<div wire:key="job-{{ $loop->index }}" class="py-2">
|
||||
<div class="flex justify-between gap-4">
|
||||
<span class="font-medium">{{ $job['job'] }}</span>
|
||||
<span class="text-zinc-500">{{ $job['failed_at']->format('d.m. H:i') }}</span>
|
||||
</div>
|
||||
<div class="text-zinc-500">{{ $job['error'] }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</flux:card>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
93
tests/Feature/Monitoring/AdminMonitoringPageTest.php
Normal file
93
tests/Feature/Monitoring/AdminMonitoringPageTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Monitoring\HealthProbe;
|
||||
use App\Services\Monitoring\SystemStatus;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Volt\Volt;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Monitoring-Seite im Admin (09.10.2026).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Role::create(['name' => '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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue