12-05-2026 Frontend dev
This commit is contained in:
parent
405df0a122
commit
5b8bdf4182
779 changed files with 480564 additions and 6241 deletions
109
app/Services/Admin/AdminPerformanceCache.php
Normal file
109
app/Services/Admin/AdminPerformanceCache.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Services/Admin/AdminRequestPerformanceMetrics.php
Normal file
78
app/Services/Admin/AdminRequestPerformanceMetrics.php
Normal 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));
|
||||
}
|
||||
}
|
||||
323
app/Services/Admin/AdminSlowRequestReporter.php
Normal file
323
app/Services/Admin/AdminSlowRequestReporter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue