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:
Kevin Adametz 2026-10-09 10:27:04 +00:00
parent 8ddc85422e
commit 061f004013
6 changed files with 525 additions and 42 deletions

View 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;
}
}

View file

@ -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'),
];

View file

@ -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 />

View 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>

View file

@ -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 () {

View 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();
});