admin: Betriebsüberwachung im Backend (Queue-Worker & Deployment-Checks)
Neue Reports-Seite (settings:manage) zeigt den Queue-Zustand (ältester wartender Job, Worker aktiv/gestoppt, fehlgeschlagene Jobs) und Deployment-Checks als Ampel: Permissions ausgerollt, APP_DEBUG, Session-Secure-Cookie, ausstehende Medien-Privatisierung. Damit ist die für Magic-Link-Login und fristrelevante Mails kritische Queue-Abhängigkeit aus dem Admin sichtbar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
23cbd0bfe2
commit
830aca5149
3 changed files with 543 additions and 0 deletions
283
app/Services/Admin/AdminSystemHealth.php
Normal file
283
app/Services/Admin/AdminSystemHealth.php
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Admin;
|
||||
|
||||
use App\Authorization\Permission as PermissionCatalog;
|
||||
use App\Models\PressReleaseAttachment;
|
||||
use App\Models\PressReleaseImage;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Betriebsüberwachung fürs Admin-Backend: Zustand der Queue (kritisch, weil
|
||||
* Magic-Link-Login und fristrelevante Bestätigungs-Mails darüber laufen) sowie
|
||||
* Deployment-/Konfigurations-Checks (neue Permissions ausgerollt, Debug/Env,
|
||||
* ausstehende Medien-Privatisierung).
|
||||
*/
|
||||
class AdminSystemHealth
|
||||
{
|
||||
private const StatusOk = 'ok';
|
||||
|
||||
private const StatusWarn = 'warn';
|
||||
|
||||
private const StatusCritical = 'critical';
|
||||
|
||||
/**
|
||||
* Sekundenschwellen für den ältesten wartenden Job: darüber gilt der Worker
|
||||
* als verzögert (warn) bzw. mutmaßlich gestoppt (critical).
|
||||
*/
|
||||
private const PendingWarnSeconds = 60;
|
||||
|
||||
private const PendingCriticalSeconds = 300;
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* generated_at: Carbon,
|
||||
* overall: string,
|
||||
* queue: array<string, mixed>,
|
||||
* checks: list<array{key: string, label: string, status: string, detail: string}>
|
||||
* }
|
||||
*/
|
||||
public function snapshot(): array
|
||||
{
|
||||
$queue = $this->queueHealth();
|
||||
$checks = $this->deploymentChecks($queue);
|
||||
|
||||
$statuses = array_merge(
|
||||
[$queue['status']],
|
||||
array_map(static fn (array $check): string => $check['status'], $checks),
|
||||
);
|
||||
|
||||
return [
|
||||
'generated_at' => now(),
|
||||
'overall' => $this->worstStatus($statuses),
|
||||
'queue' => $queue,
|
||||
'checks' => $checks,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function queueHealth(): array
|
||||
{
|
||||
$connection = (string) config('queue.default');
|
||||
$usesDatabase = $connection === 'database';
|
||||
|
||||
if (! $usesDatabase || ! Schema::hasTable('jobs')) {
|
||||
return [
|
||||
'status' => self::StatusWarn,
|
||||
'driver' => $connection,
|
||||
'supported' => false,
|
||||
'pending' => 0,
|
||||
'delayed' => 0,
|
||||
'reserved' => 0,
|
||||
'failed_total' => Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0,
|
||||
'failed_recent' => 0,
|
||||
'oldest_pending_seconds' => null,
|
||||
'worker_running' => null,
|
||||
'recent_failures' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$now = now()->getTimestamp();
|
||||
|
||||
$pending = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '<=', $now)->count();
|
||||
$delayed = DB::table('jobs')->whereNull('reserved_at')->where('available_at', '>', $now)->count();
|
||||
$reserved = DB::table('jobs')->whereNotNull('reserved_at')->count();
|
||||
|
||||
$oldestAvailable = DB::table('jobs')
|
||||
->whereNull('reserved_at')
|
||||
->where('available_at', '<=', $now)
|
||||
->min('available_at');
|
||||
|
||||
$oldestPendingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : null;
|
||||
|
||||
$failedTotal = DB::table('failed_jobs')->count();
|
||||
$failedRecent = DB::table('failed_jobs')
|
||||
->where('failed_at', '>=', now()->subDay())
|
||||
->count();
|
||||
|
||||
$recentFailures = DB::table('failed_jobs')
|
||||
->orderByDesc('failed_at')
|
||||
->limit(10)
|
||||
->get(['uuid', 'queue', 'payload', 'exception', 'failed_at'])
|
||||
->map(fn ($row): array => [
|
||||
'uuid' => $row->uuid,
|
||||
'queue' => $row->queue,
|
||||
'job' => $this->jobDisplayName($row->payload),
|
||||
'error' => $this->firstExceptionLine($row->exception),
|
||||
'failed_at' => Carbon::parse($row->failed_at),
|
||||
])
|
||||
->all();
|
||||
|
||||
// Worker gilt als aktiv, wenn kein wartender Job zu lange unbearbeitet
|
||||
// liegt. Ein reservierter Job zeigt aktive Verarbeitung an.
|
||||
$workerRunning = $oldestPendingSeconds === null || $oldestPendingSeconds < self::PendingCriticalSeconds;
|
||||
|
||||
$status = self::StatusOk;
|
||||
if ($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingCriticalSeconds) {
|
||||
$status = self::StatusCritical;
|
||||
} elseif (($oldestPendingSeconds !== null && $oldestPendingSeconds >= self::PendingWarnSeconds) || $failedRecent > 0) {
|
||||
$status = self::StatusWarn;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'driver' => $connection,
|
||||
'supported' => true,
|
||||
'pending' => $pending,
|
||||
'delayed' => $delayed,
|
||||
'reserved' => $reserved,
|
||||
'failed_total' => $failedTotal,
|
||||
'failed_recent' => $failedRecent,
|
||||
'oldest_pending_seconds' => $oldestPendingSeconds,
|
||||
'worker_running' => $workerRunning,
|
||||
'recent_failures' => $recentFailures,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $queue
|
||||
* @return list<array{key: string, label: string, status: string, detail: string}>
|
||||
*/
|
||||
private function deploymentChecks(array $queue): array
|
||||
{
|
||||
$checks = [];
|
||||
|
||||
// Queue-Worker als expliziter Check (spiegelt den Queue-Block).
|
||||
$checks[] = [
|
||||
'key' => 'queue_worker',
|
||||
'label' => 'Queue-Worker',
|
||||
'status' => $queue['status'],
|
||||
'detail' => $this->queueWorkerDetail($queue),
|
||||
];
|
||||
|
||||
// Neue Permissions ausgerollt?
|
||||
$expected = PermissionCatalog::adminOnly();
|
||||
$existing = Permission::query()->whereIn('name', $expected)->pluck('name')->all();
|
||||
$missing = array_values(array_diff($expected, $existing));
|
||||
$checks[] = [
|
||||
'key' => 'permissions',
|
||||
'label' => 'Berechtigungen ausgerollt',
|
||||
'status' => $missing === [] ? self::StatusOk : self::StatusCritical,
|
||||
'detail' => $missing === []
|
||||
? 'Alle erwarteten Rollen-Permissions sind vorhanden.'
|
||||
: 'Fehlende Permissions (Seeder ausführen): '.implode(', ', $missing),
|
||||
];
|
||||
|
||||
// APP_DEBUG in nicht-lokalen Umgebungen.
|
||||
$isLocal = app()->environment('local');
|
||||
$debug = (bool) config('app.debug');
|
||||
$checks[] = [
|
||||
'key' => 'app_debug',
|
||||
'label' => 'Debug-Modus',
|
||||
'status' => ($debug && ! $isLocal) ? self::StatusCritical : self::StatusOk,
|
||||
'detail' => $debug
|
||||
? ('APP_DEBUG ist aktiv'.($isLocal ? ' (lokal ok).' : ' — im Produktivbetrieb deaktivieren!'))
|
||||
: 'APP_DEBUG ist deaktiviert.',
|
||||
];
|
||||
|
||||
// Secure Session Cookie außerhalb lokaler Umgebung.
|
||||
$secureCookie = (bool) config('session.secure');
|
||||
$checks[] = [
|
||||
'key' => 'session_secure_cookie',
|
||||
'label' => 'Session Secure-Cookie',
|
||||
'status' => (! $secureCookie && ! $isLocal) ? self::StatusWarn : self::StatusOk,
|
||||
'detail' => $secureCookie
|
||||
? 'Session-Cookie wird nur über HTTPS gesetzt.'
|
||||
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
|
||||
];
|
||||
|
||||
// Ausstehende Medien-Privatisierung.
|
||||
$publicImages = PressReleaseImage::query()->withTrashed()->where('disk', 'public')->count();
|
||||
$publicAttachments = PressReleaseAttachment::query()->withTrashed()->where('disk', 'public')->count();
|
||||
$publicMedia = $publicImages + $publicAttachments;
|
||||
$checks[] = [
|
||||
'key' => 'media_privatization',
|
||||
'label' => 'Medien-Privatisierung',
|
||||
'status' => $publicMedia > 0 ? self::StatusWarn : self::StatusOk,
|
||||
'detail' => $publicMedia > 0
|
||||
? "Noch {$publicMedia} PM-Medien auf der public-Disk — `press-releases:privatize-media` ausführen."
|
||||
: 'Alle PM-Medien liegen auf der privaten Disk.',
|
||||
];
|
||||
|
||||
return $checks;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $queue
|
||||
*/
|
||||
private function queueWorkerDetail(array $queue): string
|
||||
{
|
||||
if (! $queue['supported']) {
|
||||
return "Queue-Treiber „{$queue['driver']}\" — Job-Metriken hier nicht verfügbar.";
|
||||
}
|
||||
|
||||
if ($queue['oldest_pending_seconds'] === null) {
|
||||
return 'Keine wartenden Jobs. Worker im Leerlauf oder aktiv.';
|
||||
}
|
||||
|
||||
$age = $this->humanizeSeconds((int) $queue['oldest_pending_seconds']);
|
||||
|
||||
if ($queue['status'] === self::StatusCritical) {
|
||||
return "Ältester wartender Job seit {$age} unbearbeitet — Worker vermutlich gestoppt.";
|
||||
}
|
||||
|
||||
return "Ältester wartender Job seit {$age} in der Warteschlange.";
|
||||
}
|
||||
|
||||
private function jobDisplayName(string $payload): string
|
||||
{
|
||||
try {
|
||||
$data = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable) {
|
||||
return 'Unbekannter Job';
|
||||
}
|
||||
|
||||
if (is_array($data) && is_string($data['displayName'] ?? null)) {
|
||||
return $data['displayName'];
|
||||
}
|
||||
|
||||
return 'Unbekannter Job';
|
||||
}
|
||||
|
||||
private function firstExceptionLine(string $exception): string
|
||||
{
|
||||
$line = trim(strtok($exception, "\n") ?: '');
|
||||
|
||||
return $line === '' ? 'Unbekannter Fehler' : mb_strimwidth($line, 0, 240, '…');
|
||||
}
|
||||
|
||||
private function humanizeSeconds(int $seconds): string
|
||||
{
|
||||
if ($seconds < 60) {
|
||||
return "{$seconds}s";
|
||||
}
|
||||
|
||||
if ($seconds < 3600) {
|
||||
return floor($seconds / 60).'min';
|
||||
}
|
||||
|
||||
return floor($seconds / 3600).'h';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $statuses
|
||||
*/
|
||||
private function worstStatus(array $statuses): string
|
||||
{
|
||||
if (in_array(self::StatusCritical, $statuses, true)) {
|
||||
return self::StatusCritical;
|
||||
}
|
||||
|
||||
if (in_array(self::StatusWarn, $statuses, true)) {
|
||||
return self::StatusWarn;
|
||||
}
|
||||
|
||||
return self::StatusOk;
|
||||
}
|
||||
}
|
||||
146
resources/views/livewire/admin/reports/system-health.blade.php
Normal file
146
resources/views/livewire/admin/reports/system-health.blade.php
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<?php
|
||||
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new #[Layout('components.layouts.app'), Title('Betriebsüberwachung')] class extends Component
|
||||
{
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('settings:manage');
|
||||
}
|
||||
|
||||
public function refresh(): void
|
||||
{
|
||||
// Löst nur ein Re-Render aus; snapshot() wird in with() neu berechnet.
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function with(AdminSystemHealth $health): array
|
||||
{
|
||||
return [
|
||||
'health' => $health->snapshot(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
@php
|
||||
$statusMeta = [
|
||||
'ok' => ['color' => 'green', 'label' => __('OK'), 'variant' => 'ok'],
|
||||
'warn' => ['color' => 'amber', 'label' => __('Achtung'), 'variant' => 'warn'],
|
||||
'critical' => ['color' => 'red', 'label' => __('Kritisch'), 'variant' => 'warn'],
|
||||
];
|
||||
$overall = $statusMeta[$health['overall']] ?? $statusMeta['warn'];
|
||||
$queue = $health['queue'];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-8">
|
||||
{{-- ============== PAGE HEADER ============== --}}
|
||||
<x-portal.page-header
|
||||
:badge="__('Admin Backend')"
|
||||
:eyebrow="__('Administration · Reports')"
|
||||
:title="__('Betriebsüberwachung')"
|
||||
:description="__('Zustand der Queue (Magic-Link-Login & fristrelevante Mails) sowie Deployment-Checks.')"
|
||||
>
|
||||
<x-slot:actions>
|
||||
<flux:badge :color="$overall['color']" size="lg">{{ __('Gesamtstatus') }}: {{ $overall['label'] }}</flux:badge>
|
||||
<flux:button size="sm" variant="filled" icon="arrow-path" wire:click="refresh">
|
||||
{{ __('Aktualisieren') }}
|
||||
</flux:button>
|
||||
</x-slot:actions>
|
||||
</x-portal.page-header>
|
||||
|
||||
<p class="text-sm text-[color:var(--color-text-muted)]">
|
||||
{{ __('Stand') }}: {{ $health['generated_at']->format('d.m.Y H:i:s') }}
|
||||
</p>
|
||||
|
||||
{{-- ============== QUEUE-KPIs ============== --}}
|
||||
<section class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<x-portal.stat-card :variant="$queue['status'] === 'ok' ? 'ok' : 'warn'" :label="__('Queue-Worker')"
|
||||
:value="$queue['supported'] ? ($queue['worker_running'] ? __('Aktiv') : __('Gestoppt?')) : __('n/a')">
|
||||
<x-slot:meta>{{ __('Treiber') }}: {{ $queue['driver'] }}</x-slot:meta>
|
||||
<x-slot:trend>
|
||||
@if ($queue['oldest_pending_seconds'] !== null)
|
||||
{{ __('ältester Job') }}: {{ $queue['oldest_pending_seconds'] }}s
|
||||
@else
|
||||
{{ __('keine Wartejobs') }}
|
||||
@endif
|
||||
</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
|
||||
<x-portal.stat-card variant="primary" :label="__('Wartende Jobs')" :value="number_format($queue['pending'])">
|
||||
<x-slot:meta>{{ __('verzögert') }}: {{ number_format($queue['delayed']) }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('in Verarbeitung') }}: {{ number_format($queue['reserved']) }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
|
||||
<x-portal.stat-card :variant="$queue['failed_recent'] > 0 ? 'warn' : 'muted'" :label="__('Fehlgeschlagen (24h)')"
|
||||
:value="number_format($queue['failed_recent'])">
|
||||
<x-slot:meta>{{ __('gesamt') }}: {{ number_format($queue['failed_total']) }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('failed_jobs') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
|
||||
<x-portal.stat-card :variant="$overall['variant']" :label="__('Gesamtstatus')" :value="$overall['label']">
|
||||
<x-slot:meta>{{ __('Betrieb') }}</x-slot:meta>
|
||||
<x-slot:trend>{{ __('Queue + Deployment') }}</x-slot:trend>
|
||||
</x-portal.stat-card>
|
||||
</section>
|
||||
|
||||
{{-- ============== DEPLOYMENT-CHECKS ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Deployment- & Konfigurations-Checks') }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($health['checks'] as $check)
|
||||
@php $meta = $statusMeta[$check['status']] ?? $statusMeta['warn']; @endphp
|
||||
<div class="flex items-start justify-between gap-4 p-4">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{{ $check['label'] }}</p>
|
||||
<p class="text-sm text-[color:var(--color-text-muted)] break-words">{{ $check['detail'] }}</p>
|
||||
</div>
|
||||
<flux:badge :color="$meta['color']" size="sm" class="shrink-0">{{ $meta['label'] }}</flux:badge>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{{-- ============== FEHLGESCHLAGENE JOBS ============== --}}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<span class="section-eyebrow">{{ __('Zuletzt fehlgeschlagene Jobs') }}</span>
|
||||
<span class="badge hub">{{ count($queue['recent_failures']) }}</span>
|
||||
</div>
|
||||
@if (count($queue['recent_failures']) === 0)
|
||||
<div class="p-5 text-sm text-[color:var(--color-text-muted)]">
|
||||
{{ __('Keine fehlgeschlagenen Jobs.') }}
|
||||
</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[color:var(--color-text-muted)]">
|
||||
<th class="p-3">{{ __('Zeitpunkt') }}</th>
|
||||
<th class="p-3">{{ __('Queue') }}</th>
|
||||
<th class="p-3">{{ __('Job') }}</th>
|
||||
<th class="p-3">{{ __('Fehler') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[color:var(--color-bg-rule)]">
|
||||
@foreach ($queue['recent_failures'] as $failure)
|
||||
<tr>
|
||||
<td class="p-3 whitespace-nowrap">{{ $failure['failed_at']->format('d.m. H:i') }}</td>
|
||||
<td class="p-3 whitespace-nowrap">{{ $failure['queue'] }}</td>
|
||||
<td class="p-3">{{ $failure['job'] }}</td>
|
||||
<td class="p-3 text-[color:var(--color-text-muted)] break-words">{{ $failure['error'] }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</article>
|
||||
</div>
|
||||
114
tests/Feature/Admin/SystemHealthTest.php
Normal file
114
tests/Feature/Admin/SystemHealthTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
// In der Testumgebung ist die Queue `sync`; für die Queue-Metriken den
|
||||
// Produktions-Treiber (database) verwenden, der die jobs-Tabellen liest.
|
||||
config()->set('queue.default', 'database');
|
||||
});
|
||||
|
||||
test('editor cannot open the system health report', function () {
|
||||
/** @var TestCase $this */
|
||||
$editor = User::factory()->create(['is_active' => true]);
|
||||
$editor->assignRole('editor');
|
||||
$this->actingAs($editor);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/reports/system-health')->assertForbidden();
|
||||
});
|
||||
|
||||
test('admin can open the system health report', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = User::factory()->create(['is_active' => true]);
|
||||
$admin->assignRole('admin');
|
||||
$this->actingAs($admin);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/reports/system-health')
|
||||
->assertSuccessful()
|
||||
->assertSee('Betriebsüberwachung')
|
||||
->assertSee('Queue-Worker');
|
||||
});
|
||||
|
||||
test('snapshot reports an ok queue when there are no waiting jobs', function () {
|
||||
/** @var TestCase $this */
|
||||
$snapshot = app(AdminSystemHealth::class)->snapshot();
|
||||
|
||||
expect($snapshot['queue']['pending'])->toBe(0)
|
||||
->and($snapshot['queue']['worker_running'])->toBeTrue()
|
||||
->and($snapshot['queue']['status'])->toBe('ok');
|
||||
});
|
||||
|
||||
test('snapshot flags a stalled worker when a job has waited too long', function () {
|
||||
/** @var TestCase $this */
|
||||
DB::table('jobs')->insert([
|
||||
'queue' => 'default',
|
||||
'payload' => json_encode(['displayName' => 'App\\Jobs\\Example']),
|
||||
'attempts' => 0,
|
||||
'reserved_at' => null,
|
||||
'available_at' => now()->subMinutes(10)->getTimestamp(),
|
||||
'created_at' => now()->subMinutes(10)->getTimestamp(),
|
||||
]);
|
||||
|
||||
$snapshot = app(AdminSystemHealth::class)->snapshot();
|
||||
|
||||
expect($snapshot['queue']['status'])->toBe('critical')
|
||||
->and($snapshot['queue']['worker_running'])->toBeFalse()
|
||||
->and($snapshot['overall'])->toBe('critical');
|
||||
});
|
||||
|
||||
test('snapshot surfaces recent failed jobs', function () {
|
||||
/** @var TestCase $this */
|
||||
DB::table('failed_jobs')->insert([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'connection' => 'database',
|
||||
'queue' => 'default',
|
||||
'payload' => json_encode(['displayName' => 'App\\Jobs\\Broken']),
|
||||
'exception' => "RuntimeException: boom\nStack trace...",
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
|
||||
$snapshot = app(AdminSystemHealth::class)->snapshot();
|
||||
|
||||
expect($snapshot['queue']['failed_recent'])->toBe(1)
|
||||
->and($snapshot['queue']['recent_failures'][0]['job'])->toBe('App\\Jobs\\Broken')
|
||||
->and($snapshot['queue']['recent_failures'][0]['error'])->toContain('boom')
|
||||
->and($snapshot['queue']['status'])->toBe('warn');
|
||||
});
|
||||
|
||||
test('snapshot flags missing permissions as critical', function () {
|
||||
/** @var TestCase $this */
|
||||
Permission::query()->where('name', 'billing:manage')->delete();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])
|
||||
->keyBy('key');
|
||||
|
||||
expect($checks['permissions']['status'])->toBe('critical')
|
||||
->and($checks['permissions']['detail'])->toContain('billing:manage');
|
||||
});
|
||||
|
||||
test('snapshot flags pending media privatization', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create();
|
||||
PressReleaseImage::query()->create([
|
||||
'press_release_id' => $pr->id,
|
||||
'disk' => 'public',
|
||||
'path' => 'press-releases/'.$pr->id.'/images/legacy.jpg',
|
||||
]);
|
||||
|
||||
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
|
||||
expect($checks['media_privatization']['status'])->toBe('warn')
|
||||
->and($checks['media_privatization']['detail'])->toContain('1');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue