Files
newznab-tmux-NNTmux/app/Services/AdminDashboardSnapshotService.php
T
2026-05-22 15:12:17 +02:00

253 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Payment;
use App\Models\Release;
use App\Models\ReleaseReport;
use App\Models\UsenetGroup;
use App\Models\User;
use App\Models\UserActivity;
use App\Support\ApproximateRowCount;
use Illuminate\Support\Facades\Cache;
/**
* Builds (and caches) the entire admin dashboard payload in a single
* Cache::flexible() entry so the controller never blocks on cold queries.
*
* - Fresh window: 60s
* - Stale-while-revalidate window: 600s
* - Warmed every minute by the `admin:warm-dashboard` scheduled command.
*/
class AdminDashboardSnapshotService
{
public const CACHE_KEY = 'admin:dashboard:snapshot';
/**
* @var array{0: int, 1: int}
*/
private const CACHE_TTL = [60, 600];
public function __construct(
private readonly UserStatsService $userStatsService,
private readonly SystemMetricsService $systemMetricsService,
private readonly RegistrationStatusService $registrationStatusService,
private readonly SiteStatusService $siteStatusService,
) {}
/**
* Returns the cached dashboard snapshot, building it on first miss.
*
* @return array<string, mixed>
*/
public function get(): array
{
return Cache::flexible(self::CACHE_KEY, self::CACHE_TTL, fn (): array => $this->build());
}
/**
* Forcibly rebuild and re-cache the snapshot. Used by the warmer command
* and by admin actions that want immediate freshness.
*
* @return array<string, mixed>
*/
public function warm(): array
{
$payload = $this->build();
// Re-prime both the value and the SWR control key used by Cache::flexible().
Cache::forget(self::CACHE_KEY);
return Cache::flexible(self::CACHE_KEY, self::CACHE_TTL, static fn (): array => $payload);
}
public function forget(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* @return array<string, mixed>
*/
private function build(): array
{
$now = now();
$registrationStatus = $this->registrationStatusService->resolve($now);
$nextRegistrationPeriod = $registrationStatus['active_period'] === null
? $this->registrationStatusService->getNextUpcomingPeriod($now)
: null;
return [
'generated_at' => $now->toIso8601String(),
'stats' => $this->buildStats(),
'userStats' => $this->buildUserStats(),
'systemMetrics' => $this->buildSystemMetrics(),
'recent_activity' => $this->buildRecentActivity(),
'recent_payments' => $this->buildRecentPayments(),
'serviceStatuses' => $this->siteStatusService->getEnabledServices(),
'activeIncidents' => $this->siteStatusService->getActiveIncidents(),
'registrationStatus' => $registrationStatus,
'nextRegistrationPeriod' => $nextRegistrationPeriod,
];
}
/**
* @return array<string, mixed>
*/
private function buildStats(): array
{
$today = now()->format('Y-m-d');
// Approximate counts on huge tables — InnoDB metadata estimates are
// good enough for the admin overview tiles.
$releasesCount = ApproximateRowCount::for('releases');
$usersApproximateCount = ApproximateRowCount::for('users');
$groupsCount = ApproximateRowCount::for('usenet_groups');
$failedCount = ApproximateRowCount::for('dnzb_failures');
// Small / index-friendly aggregates kept exact.
$activeGroupsCount = UsenetGroup::where('active', 1)->count();
$reportedCount = ReleaseReport::where('status', 'pending')->count();
$softDeletedCount = User::onlyTrashed()->count();
$activeUsersCount = max($usersApproximateCount - $softDeletedCount, 0);
// Replaces previous whereJsonContains() count which couldn't use an index.
$permanentlyDeletedCount = UserActivity::query()
->where('activity_type', 'deleted')
->where('is_permanent', true)
->count();
$releasesToday = Release::whereDate('adddate', $today)->count();
$usersToday = User::whereNull('deleted_at')->whereDate('created_at', $today)->count();
return [
'releases' => $releasesCount,
'releases_today' => $releasesToday,
'users' => $activeUsersCount,
'users_today' => $usersToday,
'groups' => $groupsCount,
'active_groups' => $activeGroupsCount,
'failed' => $failedCount,
'reported' => $reportedCount,
'soft_deleted_users' => $softDeletedCount,
'permanently_deleted_users' => $permanentlyDeletedCount,
'disk_free' => $this->systemMetricsService->getDiskSpace(),
];
}
/**
* @return array<string, mixed>
*/
private function buildUserStats(): array
{
return [
'users_by_role' => $this->userStatsService->getUsersByRole(),
'downloads_per_hour' => $this->userStatsService->getDownloadsPerHour(168),
'downloads_per_minute' => $this->userStatsService->getDownloadsPerMinute(60),
'api_hits_per_hour' => $this->userStatsService->getApiHitsPerHour(168),
'api_hits_per_minute' => $this->userStatsService->getApiHitsPerMinute(60),
'summary' => $this->userStatsService->getSummaryStats(),
'top_downloaders' => $this->userStatsService->getTopDownloaders(5),
];
}
/**
* @return array<string, mixed>
*/
private function buildSystemMetrics(): array
{
$cpuInfo = $this->systemMetricsService->getCpuInfo();
$cpuUsage = $this->systemMetricsService->getCpuUsage();
$ramUsage = $this->systemMetricsService->getRamUsage();
$loadAverage = $this->systemMetricsService->getLoadAverage();
return [
'cpu' => [
'current' => $cpuUsage,
'label' => 'CPU Usage',
'history_24h' => $this->systemMetricsService->getHourlyMetrics('cpu', 24),
'history_30d' => $this->systemMetricsService->getDailyMetrics('cpu', 30),
'cores' => $cpuInfo['cores'],
'threads' => $cpuInfo['threads'],
'model' => $cpuInfo['model'],
'load_average' => $loadAverage,
],
'ram' => [
'used' => $ramUsage['used'],
'total' => $ramUsage['total'],
'percentage' => $ramUsage['percentage'],
'label' => 'RAM Usage',
'history_24h' => $this->systemMetricsService->getHourlyMetrics('ram', 24),
'history_30d' => $this->systemMetricsService->getDailyMetrics('ram', 30),
],
];
}
/**
* @return array<int, object>
*/
private function buildRecentActivity(): array
{
$activities = UserActivity::orderBy('created_at', 'desc')
->limit(10)
->get();
return $activities->map(static function ($activity): object {
return (object) [
'type' => $activity->activity_type,
'message' => $activity->description,
'icon' => $activity->icon,
'icon_bg' => 'bg-'.$activity->color_class.'-100 dark:bg-'.$activity->color_class.'-900',
'icon_color' => 'text-'.$activity->color_class.'-600 dark:text-'.$activity->color_class.'-400',
'created_at' => $activity->created_at,
'username' => $activity->username,
'metadata' => $activity->metadata,
];
})->all();
}
/**
* Build the "Last 5 payments" widget payload (most recent records by id desc).
*
* @return array<int, array<string, mixed>>
*/
private function buildRecentPayments(): array
{
return Payment::query()
->orderByDesc('id')
->limit(5)
->get([
'id',
'created_at',
'username',
'email',
'item_description',
'invoice_amount',
'payment_method',
'payment_status',
'invoice_status',
'order_id',
])
->map(static function (Payment $payment): array {
$createdAt = $payment->created_at;
return [
'id' => (int) $payment->id,
'username' => (string) $payment->username,
'email' => (string) $payment->email,
'item_description' => (string) $payment->item_description,
'invoice_amount' => (string) $payment->invoice_amount,
'payment_method' => (string) $payment->payment_method,
'payment_status' => (string) $payment->payment_status,
'invoice_status' => (string) ($payment->invoice_status ?? Payment::INVOICE_STATUS_PENDING),
'order_id' => (string) $payment->order_id,
'created_at' => $createdAt?->toIso8601String(),
'created_at_human' => $createdAt?->diffForHumans(),
'created_at_formatted' => $createdAt?->timezone(config('app.timezone'))->format('Y-m-d H:i'),
];
})
->all();
}
}