12-05-2026 Frontend dev
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

This commit is contained in:
Kevin Adametz 2026-05-12 18:32:33 +02:00
parent 405df0a122
commit 5b8bdf4182
779 changed files with 480564 additions and 6241 deletions

View file

@ -0,0 +1,109 @@
<?php
namespace App\Services\Admin;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use Illuminate\Support\Facades\Cache;
class AdminPerformanceCache
{
public const DashboardStats = 'admin.dashboard.stats';
public const PressReleaseStats = 'admin.press-releases.stats';
public const PressReleaseReviewCount = 'admin.press-releases.review-count';
public const PressReleaseCategoryOptions = 'admin.press-releases.category-options';
public const ActiveCategoryOptions = 'admin.categories.active-options';
public const RoleOptions = 'admin.roles.options';
public const UserStats = 'admin.users.stats';
public const NewsletterStats = 'admin.newsletter.stats';
public const PresetAreas = 'admin.presets.areas';
public const PresetTypes = 'admin.presets.types';
public const StatsTtl = 60;
public const OptionsTtl = 300;
public function remember(string $key, int $seconds, callable $callback): mixed
{
if (app()->runningUnitTests()) {
return $callback();
}
return Cache::memo()->remember($key, $seconds, $callback);
}
public function flush(): void
{
foreach ($this->keys() as $key) {
Cache::memo()->forget($key);
}
}
public function pressReleaseReviewCount(): int
{
return (int) $this->remember(self::PressReleaseReviewCount, self::StatsTtl, function (): int {
return PressRelease::query()
->withoutGlobalScopes()
->where('status', PressReleaseStatus::Review->value)
->count();
});
}
public function companiesStatsKey(?string $portal): string
{
return 'admin.companies.stats.'.($portal ?? 'all');
}
public function contactsStatsKey(?string $portal): string
{
return 'admin.contacts.stats.'.($portal ?? 'all');
}
public function categoriesStatsKey(?string $portal): string
{
return 'admin.categories.stats.'.($portal ?? 'all');
}
public function permissionGroupsKey(string $guard): string
{
return "admin.permissions.groups.{$guard}";
}
/**
* @return list<string>
*/
private function keys(): array
{
$portalKeys = collect([null, ...array_map(
static fn (Portal $portal): string => $portal->value,
Portal::cases(),
)]);
return [
self::DashboardStats,
self::PressReleaseStats,
self::PressReleaseReviewCount,
self::PressReleaseCategoryOptions,
self::ActiveCategoryOptions,
self::RoleOptions,
self::UserStats,
self::NewsletterStats,
self::PresetAreas,
self::PresetTypes,
$this->permissionGroupsKey('web'),
...$portalKeys->map(fn (?string $portal): string => $this->companiesStatsKey($portal))->all(),
...$portalKeys->map(fn (?string $portal): string => $this->contactsStatsKey($portal))->all(),
...$portalKeys->map(fn (?string $portal): string => $this->categoriesStatsKey($portal))->all(),
];
}
}

View file

@ -0,0 +1,78 @@
<?php
namespace App\Services\Admin;
use Illuminate\Database\Events\QueryExecuted;
class AdminRequestPerformanceMetrics
{
private bool $active = false;
private int $queryCount = 0;
private float $databaseTimeMs = 0.0;
/**
* @var list<array{sql: string, time_ms: float, connection: ?string}>
*/
private array $slowQueries = [];
public function start(): void
{
$this->active = true;
$this->queryCount = 0;
$this->databaseTimeMs = 0.0;
$this->slowQueries = [];
}
/**
* @return array{database_time_ms: float, query_count: int, slow_queries: list<array{sql: string, time_ms: float, connection: ?string}>}
*/
public function snapshot(): array
{
return [
'database_time_ms' => round($this->databaseTimeMs, 2),
'query_count' => $this->queryCount,
'slow_queries' => $this->slowQueries,
];
}
public function stop(): void
{
$this->active = false;
}
public function record(QueryExecuted $query): void
{
if (! $this->active) {
return;
}
$this->queryCount++;
$this->databaseTimeMs += $query->time;
if ($query->time < $this->slowQueryThresholdMs()) {
return;
}
if (count($this->slowQueries) >= $this->maxSlowQueries()) {
return;
}
$this->slowQueries[] = [
'sql' => $query->sql,
'time_ms' => round($query->time, 2),
'connection' => $query->connectionName,
];
}
private function slowQueryThresholdMs(): int
{
return (int) config('admin_performance.slow_requests.slow_query_threshold_ms', 50);
}
private function maxSlowQueries(): int
{
return max(0, (int) config('admin_performance.slow_requests.max_slow_queries', 5));
}
}

View file

@ -0,0 +1,323 @@
<?php
namespace App\Services\Admin;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Throwable;
class AdminSlowRequestReporter
{
/**
* @param array{
* from?: ?string,
* to?: ?string,
* route?: ?string,
* path?: ?string,
* status?: ?int,
* min_duration_ms?: ?int
* } $filters
* @param list<string>|null $paths
* @return array{
* summary: array<string, int|float|string|null>,
* top_routes: list<array<string, mixed>>,
* top_paths: list<array<string, mixed>>,
* status_codes: list<array<string, mixed>>,
* slowest_requests: list<array<string, mixed>>,
* query_heavy_requests: list<array<string, mixed>>,
* slow_queries: list<array<string, mixed>>,
* explain_plans: list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>,
* entries: list<array<string, mixed>>,
* files: list<string>
* }
*/
public function report(array $filters = [], int $top = 10, int $limit = 50, ?array $paths = null): array
{
$top = max(1, $top);
$limit = max(1, $limit);
$files = $this->files($paths);
$entries = $this->entries($files)
->filter(fn (array $entry): bool => $this->matchesFilters($entry, $filters))
->sortByDesc('timestamp')
->values();
$slowQueries = $this->slowQueries($entries, $top);
return [
'summary' => $this->summary($entries, $files),
'top_routes' => $this->topBy($entries, 'route_name', $top),
'top_paths' => $this->topBy($entries, 'path', $top),
'status_codes' => $this->topBy($entries, 'status_code', $top),
'slowest_requests' => $this->slowestRequests($entries, $limit),
'query_heavy_requests' => $this->queryHeavyRequests($entries, $limit),
'slow_queries' => $slowQueries,
'explain_plans' => $this->explainPlans($slowQueries),
'entries' => $entries->take($limit)->values()->all(),
'files' => $files,
];
}
/**
* @param list<string>|null $paths
* @return list<string>
*/
private function files(?array $paths): array
{
if ($paths !== null && $paths !== []) {
return collect($paths)
->flatMap(fn (string $path): array => glob($path) ?: [$path])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->unique()
->sort()
->values()
->all();
}
$configuredPath = (string) config('logging.channels.admin_slow.path', storage_path('logs/admin-slow.log'));
$directory = dirname($configuredPath);
$basename = pathinfo($configuredPath, PATHINFO_FILENAME);
return collect(glob($directory.'/'.$basename.'*.log') ?: [])
->filter(fn (string $path): bool => File::isFile($path) && File::isReadable($path))
->sortDesc()
->values()
->all();
}
/**
* @param list<string> $files
* @return Collection<int, array<string, mixed>>
*/
private function entries(array $files): Collection
{
return collect($files)
->flatMap(function (string $file): array {
return collect(File::lines($file))
->map(fn (string $line): ?array => $this->parseLine($line, $file))
->filter()
->values()
->all();
})
->values();
}
/**
* @return array<string, mixed>|null
*/
private function parseLine(string $line, string $file): ?array
{
if (! str_contains($line, 'Slow admin request detected.')) {
return null;
}
if (! preg_match('/^\[(?<timestamp>[^\]]+)\]\s+(?<environment>[^.]+)\.(?<level>[^:]+):\s+Slow admin request detected\.\s+(?<context>\{.*\})\s*$/', $line, $matches)) {
return null;
}
$context = json_decode($matches['context'], true);
if (! is_array($context)) {
return null;
}
$timestamp = CarbonImmutable::parse($matches['timestamp']);
return [
'timestamp' => $timestamp->toDateTimeString(),
'environment' => $matches['environment'],
'level' => strtolower($matches['level']),
'file' => $file,
'method' => (string) ($context['method'] ?? ''),
'path' => (string) ($context['path'] ?? ''),
'route_name' => (string) ($context['route_name'] ?? 'unknown'),
'status_code' => (int) ($context['status_code'] ?? 0),
'user_id' => isset($context['user_id']) ? (int) $context['user_id'] : null,
'duration_ms' => (int) ($context['duration_ms'] ?? 0),
'database_time_ms' => (float) ($context['database_time_ms'] ?? 0),
'query_count' => (int) ($context['query_count'] ?? 0),
'slow_queries' => is_array($context['slow_queries'] ?? null) ? $context['slow_queries'] : [],
];
}
/**
* @param array<string, mixed> $entry
* @param array<string, mixed> $filters
*/
private function matchesFilters(array $entry, array $filters): bool
{
$timestamp = CarbonImmutable::parse((string) $entry['timestamp']);
if (filled($filters['from'] ?? null) && $timestamp->lt(CarbonImmutable::parse((string) $filters['from']))) {
return false;
}
if (filled($filters['to'] ?? null) && $timestamp->gt(CarbonImmutable::parse((string) $filters['to']))) {
return false;
}
if (filled($filters['route'] ?? null) && ! Str::contains((string) $entry['route_name'], (string) $filters['route'], true)) {
return false;
}
if (filled($filters['path'] ?? null) && ! Str::contains((string) $entry['path'], (string) $filters['path'], true)) {
return false;
}
if (($filters['status'] ?? null) !== null && (int) $entry['status_code'] !== (int) $filters['status']) {
return false;
}
if (($filters['min_duration_ms'] ?? null) !== null && (int) $entry['duration_ms'] < (int) $filters['min_duration_ms']) {
return false;
}
return true;
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @param list<string> $files
* @return array<string, int|float|string|null>
*/
private function summary(Collection $entries, array $files): array
{
return [
'files' => count($files),
'total_requests' => $entries->count(),
'unique_routes' => $entries->pluck('route_name')->filter()->unique()->count(),
'average_duration_ms' => round((float) $entries->avg('duration_ms'), 2),
'max_duration_ms' => (int) $entries->max('duration_ms'),
'average_database_time_ms' => round((float) $entries->avg('database_time_ms'), 2),
'max_database_time_ms' => round((float) $entries->max('database_time_ms'), 2),
'max_query_count' => (int) $entries->max('query_count'),
'period_from' => $entries->min('timestamp'),
'period_to' => $entries->max('timestamp'),
];
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function topBy(Collection $entries, string $key, int $top): array
{
return $entries
->groupBy(fn (array $entry): string => (string) ($entry[$key] ?: 'unknown'))
->map(fn (Collection $group, string $value): array => [
'value' => $value,
'requests' => $group->count(),
'average_duration_ms' => round((float) $group->avg('duration_ms'), 2),
'max_duration_ms' => (int) $group->max('duration_ms'),
'average_database_time_ms' => round((float) $group->avg('database_time_ms'), 2),
'total_queries' => (int) $group->sum('query_count'),
])
->sortByDesc('requests')
->take($top)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowestRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('duration_ms')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function queryHeavyRequests(Collection $entries, int $limit): array
{
return $entries
->sortByDesc('query_count')
->take($limit)
->values()
->all();
}
/**
* @param Collection<int, array<string, mixed>> $entries
* @return list<array<string, mixed>>
*/
private function slowQueries(Collection $entries, int $top): array
{
return $entries
->flatMap(fn (array $entry): array => $entry['slow_queries'])
->filter(fn (mixed $query): bool => is_array($query) && isset($query['sql']))
->groupBy(fn (array $query): string => (string) $query['sql'])
->map(fn (Collection $group, string $sql): array => [
'sql' => $sql,
'occurrences' => $group->count(),
'max_time_ms' => round((float) $group->max('time_ms'), 2),
'average_time_ms' => round((float) $group->avg('time_ms'), 2),
'connection' => $group->pluck('connection')->filter()->first(),
])
->sortByDesc('occurrences')
->take($top)
->values()
->all();
}
/**
* @param list<array<string, mixed>> $slowQueries
* @return list<array{sql: string, explainable: bool, plan: list<array<string, mixed>>, error: ?string}>
*/
private function explainPlans(array $slowQueries): array
{
return collect($slowQueries)
->map(function (array $query): array {
$sql = trim((string) $query['sql']);
if (! $this->isExplainable($sql)) {
return [
'sql' => $sql,
'explainable' => false,
'plan' => [],
'error' => 'Nur SELECT/CTE-Queries werden automatisch erklärt.',
];
}
try {
$bindings = array_fill(0, substr_count($sql, '?'), null);
$prefix = DB::connection()->getDriverName() === 'sqlite'
? 'EXPLAIN QUERY PLAN '
: 'EXPLAIN ';
return [
'sql' => $sql,
'explainable' => true,
'plan' => array_map(
static fn (object $row): array => (array) $row,
DB::select($prefix.$sql, $bindings),
),
'error' => null,
];
} catch (Throwable $exception) {
return [
'sql' => $sql,
'explainable' => true,
'plan' => [],
'error' => $exception->getMessage(),
];
}
})
->values()
->all();
}
private function isExplainable(string $sql): bool
{
return preg_match('/^\s*(select|with)\b/i', $sql) === 1;
}
}