mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update admin dashboard
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class WarmAdminDashboard extends Command
|
||||
{
|
||||
protected $signature = 'admin:warm-dashboard';
|
||||
|
||||
protected $description = 'Rebuild and re-cache the admin dashboard snapshot (Cache::flexible payload)';
|
||||
|
||||
public function handle(AdminDashboardSnapshotService $snapshot): int
|
||||
{
|
||||
$start = microtime(true);
|
||||
$snapshot->warm();
|
||||
$elapsed = (int) ((microtime(true) - $start) * 1000);
|
||||
|
||||
$this->info("Admin dashboard snapshot warmed in {$elapsed} ms.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\BasePageController;
|
||||
use App\Models\DnzbFailure;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseReport;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Models\User;
|
||||
use App\Models\UserActivity;
|
||||
use App\Services\AdminDashboardSnapshotService;
|
||||
use App\Services\RegistrationStatusService;
|
||||
use App\Services\SiteStatusService;
|
||||
use App\Services\SystemMetricsService;
|
||||
use App\Services\UserStatsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AdminPageController extends BasePageController
|
||||
{
|
||||
@@ -28,17 +22,21 @@ class AdminPageController extends BasePageController
|
||||
|
||||
protected SiteStatusService $siteStatusService;
|
||||
|
||||
protected AdminDashboardSnapshotService $snapshot;
|
||||
|
||||
public function __construct(
|
||||
UserStatsService $userStatsService,
|
||||
SystemMetricsService $systemMetricsService,
|
||||
RegistrationStatusService $registrationStatusService,
|
||||
SiteStatusService $siteStatusService
|
||||
SiteStatusService $siteStatusService,
|
||||
AdminDashboardSnapshotService $snapshot,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->userStatsService = $userStatsService;
|
||||
$this->systemMetricsService = $systemMetricsService;
|
||||
$this->registrationStatusService = $registrationStatusService;
|
||||
$this->siteStatusService = $siteStatusService;
|
||||
$this->snapshot = $snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,172 +45,92 @@ class AdminPageController extends BasePageController
|
||||
public function index(): mixed
|
||||
{
|
||||
$this->setAdminPrefs();
|
||||
$now = now();
|
||||
|
||||
$userStats = Cache::remember('admin_user_stats', 120, function () {
|
||||
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),
|
||||
];
|
||||
});
|
||||
|
||||
$registrationStatus = $this->registrationStatusService->resolve($now);
|
||||
$nextRegistrationPeriod = $registrationStatus['active_period'] === null
|
||||
? $this->registrationStatusService->getNextUpcomingPeriod($now)
|
||||
: null;
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
return view('admin.dashboard', array_merge($this->viewData, [
|
||||
'meta_title' => 'Admin Home',
|
||||
'meta_description' => 'Admin home page',
|
||||
'userStats' => $userStats,
|
||||
'stats' => $this->getDefaultStats(),
|
||||
'systemMetrics' => $this->getSystemMetrics(),
|
||||
'registrationStatus' => $registrationStatus,
|
||||
'nextRegistrationPeriod' => $nextRegistrationPeriod,
|
||||
'recent_activity' => $this->getRecentUserActivity(),
|
||||
'serviceStatuses' => $this->siteStatusService->getEnabledServices(),
|
||||
'activeIncidents' => $this->siteStatusService->getActiveIncidents(),
|
||||
// Lightweight data is rendered server-side; heavy widgets fetch on mount.
|
||||
'stats' => $payload['stats'],
|
||||
'registrationStatus' => $payload['registrationStatus'],
|
||||
'nextRegistrationPeriod' => $payload['nextRegistrationPeriod'],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
* JSON payload consumed by the dashboard's deferred widgets (User Statistics,
|
||||
* System Resources history, Recent Activity, Site Status). Reads from the
|
||||
* same snapshot cache the index uses, so the warmer keeps it hot.
|
||||
*/
|
||||
protected function getRecentUserActivity(): array
|
||||
public function getDashboardData(): JsonResponse
|
||||
{
|
||||
return Cache::remember('admin_recent_activity', 60, function () {
|
||||
$activities = UserActivity::orderBy('created_at', 'desc')
|
||||
->limit(10)
|
||||
->get();
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
return $activities->map(function ($activity) {
|
||||
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,
|
||||
];
|
||||
})->toArray();
|
||||
});
|
||||
$serviceStatuses = collect($payload['serviceStatuses'])->map(static fn ($svc) => [
|
||||
'name' => $svc->name,
|
||||
'slug' => $svc->slug,
|
||||
'check_type' => $svc->check_type,
|
||||
'status' => $svc->status->value,
|
||||
'status_label' => $svc->status->label(),
|
||||
'uptime_percentage' => (float) $svc->uptime_percentage,
|
||||
])->values();
|
||||
|
||||
$activeIncidents = collect($payload['activeIncidents'])->map(static fn ($incident) => [
|
||||
'id' => $incident->id,
|
||||
'title' => $incident->title,
|
||||
'impact' => $incident->impact->value,
|
||||
'impact_label' => $incident->impact->label(),
|
||||
'started_at' => $incident->started_at?->toIso8601String(),
|
||||
'started_at_human' => $incident->started_at?->diffForHumans(),
|
||||
'is_auto' => (bool) $incident->is_auto,
|
||||
'services' => $incident->services->pluck('name')->all(),
|
||||
])->values();
|
||||
|
||||
$recentActivity = array_map(static fn ($activity): array => [
|
||||
'type' => $activity->type,
|
||||
'message' => $activity->message,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => $activity->icon_bg,
|
||||
'icon_color' => $activity->icon_color,
|
||||
'created_at' => $activity->created_at?->toIso8601String(),
|
||||
'created_at_human' => $activity->created_at?->diffForHumans(),
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
], $payload['recent_activity']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'generated_at' => $payload['generated_at'],
|
||||
'userStats' => $payload['userStats'],
|
||||
'systemMetrics' => $payload['systemMetrics'],
|
||||
'recent_activity' => $recentActivity,
|
||||
'serviceStatuses' => $serviceStatuses,
|
||||
'activeIncidents' => $activeIncidents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRecentActivity(): JsonResponse
|
||||
{
|
||||
$activities = $this->getRecentUserActivity();
|
||||
$payload = $this->snapshot->get();
|
||||
|
||||
$activities = array_map(static fn ($activity): array => [
|
||||
'type' => $activity->type,
|
||||
'message' => $activity->message,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => $activity->icon_bg,
|
||||
'icon_color' => $activity->icon_color,
|
||||
'created_at' => $activity->created_at?->diffForHumans(),
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
], $payload['recent_activity']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'activities' => array_map(function ($activity) {
|
||||
return [
|
||||
'type' => $activity->type,
|
||||
'message' => $activity->message,
|
||||
'icon' => $activity->icon,
|
||||
'icon_bg' => $activity->icon_bg,
|
||||
'icon_color' => $activity->icon_color,
|
||||
'created_at' => $activity->created_at->diffForHumans(),
|
||||
'username' => $activity->username,
|
||||
'metadata' => $activity->metadata,
|
||||
];
|
||||
}, $activities),
|
||||
'activities' => $activities,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getDefaultStats(): array
|
||||
{
|
||||
$today = now()->format('Y-m-d');
|
||||
|
||||
$releasesCount = Cache::remember('admin_stats_releases_count', 300, fn () => Release::count());
|
||||
|
||||
$usersCount = Cache::remember('admin_stats_users_count', 300, fn () => User::whereNull('deleted_at')->count());
|
||||
|
||||
$groupsCount = Cache::remember('admin_stats_groups_count', 600, fn () => UsenetGroup::count());
|
||||
|
||||
$activeGroupsCount = Cache::remember('admin_stats_active_groups_count', 600, fn () => UsenetGroup::where('active', 1)->count());
|
||||
|
||||
$failedCount = Cache::remember('admin_stats_failed_count', 300, fn () => DnzbFailure::count());
|
||||
|
||||
$reportedCount = Cache::remember('admin_stats_reported_count', 300, fn () => ReleaseReport::where('status', 'pending')->count());
|
||||
|
||||
$softDeletedCount = Cache::remember('admin_stats_soft_deleted_users_count', 300, fn () => User::onlyTrashed()->count());
|
||||
|
||||
$permanentlyDeletedCount = Cache::remember('admin_stats_permanently_deleted_users_count', 300, fn () => UserActivity::where('activity_type', 'deleted')
|
||||
->whereJsonContains('metadata->permanent', true)
|
||||
->count());
|
||||
|
||||
$releasesToday = Cache::remember('admin_stats_releases_today_'.$today, 60, fn () => Release::whereDate('adddate', $today)->count());
|
||||
|
||||
$usersToday = Cache::remember('admin_stats_users_today_'.$today, 60, fn () => User::whereNull('deleted_at')->whereDate('created_at', $today)->count());
|
||||
|
||||
return [
|
||||
'releases' => $releasesCount,
|
||||
'releases_today' => $releasesToday,
|
||||
'users' => $usersCount,
|
||||
'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>
|
||||
*/
|
||||
protected function getSystemMetrics(): array
|
||||
{
|
||||
$cpuInfo = Cache::remember('admin_cpu_info', 3600, fn () => $this->systemMetricsService->getCpuInfo());
|
||||
|
||||
$cpuUsage = Cache::remember('admin_cpu_usage', 30, fn () => $this->systemMetricsService->getCpuUsage());
|
||||
|
||||
$ramUsage = Cache::remember('admin_ram_usage', 30, fn () => $this->systemMetricsService->getRamUsage());
|
||||
|
||||
$loadAverage = Cache::remember('admin_load_average', 30, fn () => $this->systemMetricsService->getLoadAverage());
|
||||
|
||||
$cpuHistory24h = Cache::remember('admin_cpu_history_24h', 300, fn () => $this->systemMetricsService->getHourlyMetrics('cpu', 24));
|
||||
|
||||
$cpuHistory30d = Cache::remember('admin_cpu_history_30d', 300, fn () => $this->systemMetricsService->getDailyMetrics('cpu', 30));
|
||||
|
||||
$ramHistory24h = Cache::remember('admin_ram_history_24h', 300, fn () => $this->systemMetricsService->getHourlyMetrics('ram', 24));
|
||||
|
||||
$ramHistory30d = Cache::remember('admin_ram_history_30d', 300, fn () => $this->systemMetricsService->getDailyMetrics('ram', 30));
|
||||
|
||||
return [
|
||||
'cpu' => [
|
||||
'current' => $cpuUsage,
|
||||
'label' => 'CPU Usage',
|
||||
'history_24h' => $cpuHistory24h,
|
||||
'history_30d' => $cpuHistory30d,
|
||||
'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' => $ramHistory24h,
|
||||
'history_30d' => $ramHistory30d,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getUserActivityMinutes(): JsonResponse
|
||||
{
|
||||
$downloadsPerMinute = $this->userStatsService->getDownloadsPerMinute(60);
|
||||
@@ -268,3 +186,4 @@ class AdminPageController extends BasePageController
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,13 @@ class UserActivity extends Model
|
||||
'activity_type',
|
||||
'description',
|
||||
'metadata',
|
||||
'is_permanent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'is_permanent' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -145,6 +145,7 @@ class UserActivityObserver
|
||||
'username' => $user->username,
|
||||
'activity_type' => 'deleted',
|
||||
'description' => "User permanently deleted: {$user->username}",
|
||||
'is_permanent' => true,
|
||||
'metadata' => [
|
||||
'email' => $user->email,
|
||||
'deleted_by' => $deletedBy,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DnzbFailure;
|
||||
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(),
|
||||
'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');
|
||||
$usersCount = 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();
|
||||
|
||||
// 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' => $usersCount,
|
||||
'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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,26 @@ use App\Services\StatusProbes\ProbeResult;
|
||||
use App\Services\StatusProbes\ServiceProbeRegistry;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SiteStatusService
|
||||
{
|
||||
public const CACHE_KEY_ENABLED_SERVICES = 'admin:site-status:enabled-services';
|
||||
|
||||
public const CACHE_KEY_ACTIVE_INCIDENTS = 'admin:site-status:active-incidents';
|
||||
|
||||
/**
|
||||
* Cache freshness/stale window in seconds for the dashboard-facing reads.
|
||||
* 60s fresh + 600s stale-while-revalidate keeps reads hot without
|
||||
* blocking the request when health-check writes invalidate the keys.
|
||||
*
|
||||
* @var array{0: int, 1: int}
|
||||
*/
|
||||
private const CACHE_TTL = [60, 600];
|
||||
|
||||
public function __construct(
|
||||
private readonly ServiceProbeRegistry $probeRegistry,
|
||||
) {}
|
||||
@@ -37,11 +51,11 @@ class SiteStatusService
|
||||
*/
|
||||
public function getEnabledServices(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return ServiceStatus::query()
|
||||
return Cache::flexible(self::CACHE_KEY_ENABLED_SERVICES, self::CACHE_TTL, fn () => ServiceStatus::query()
|
||||
->where('is_enabled', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
->get());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,11 +120,22 @@ class SiteStatusService
|
||||
*/
|
||||
public function getActiveIncidents(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return ServiceIncident::query()
|
||||
->with('services')
|
||||
return Cache::flexible(self::CACHE_KEY_ACTIVE_INCIDENTS, self::CACHE_TTL, fn () => ServiceIncident::query()
|
||||
->with(['services:id,name,slug,check_type'])
|
||||
->where('status', '!=', IncidentStatusEnum::Resolved->value)
|
||||
->orderByDesc('started_at')
|
||||
->get();
|
||||
->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget cached service-status / incident reads. Call this from any code
|
||||
* path that mutates ServiceStatus or ServiceIncident rows.
|
||||
*/
|
||||
public function forgetDashboardCaches(): void
|
||||
{
|
||||
Cache::forget(self::CACHE_KEY_ENABLED_SERVICES);
|
||||
Cache::forget(self::CACHE_KEY_ACTIVE_INCIDENTS);
|
||||
Cache::forget(\App\Services\AdminDashboardSnapshotService::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,6 +263,8 @@ class SiteStatusService
|
||||
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +326,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -332,6 +361,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -348,6 +379,8 @@ class SiteStatusService
|
||||
$this->recomputeServiceHealth($service);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return $incident->fresh(['services']);
|
||||
}
|
||||
|
||||
@@ -357,6 +390,8 @@ class SiteStatusService
|
||||
'status' => $status,
|
||||
'last_checked_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
public function recomputeServiceHealth(ServiceStatus $service): void
|
||||
@@ -370,6 +405,7 @@ class SiteStatusService
|
||||
|
||||
if ($active->isEmpty()) {
|
||||
$service->update(['status' => ServiceStatusEnum::Operational]);
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -383,6 +419,7 @@ class SiteStatusService
|
||||
}
|
||||
|
||||
$service->update(['status' => $worst]);
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
private const int SLOW_THRESHOLD_MS = 5000;
|
||||
@@ -541,10 +578,14 @@ class SiteStatusService
|
||||
$this->sendIncidentEmail($existing->fresh(['services']), resolved: true);
|
||||
}
|
||||
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($existing !== null) {
|
||||
$this->forgetDashboardCaches();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,6 +605,7 @@ class SiteStatusService
|
||||
|
||||
$this->recomputeServiceHealth($service);
|
||||
$this->sendIncidentEmail($incident, resolved: false);
|
||||
$this->forgetDashboardCaches();
|
||||
}
|
||||
|
||||
private function sendIncidentEmail(ServiceIncident $incident, bool $resolved): void
|
||||
|
||||
@@ -129,17 +129,18 @@ class UserStatsService
|
||||
->where('timestamp', '>=', $startTime)
|
||||
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
|
||||
->orderBy('minute', 'asc')
|
||||
->get();
|
||||
->get()
|
||||
->keyBy('minute');
|
||||
|
||||
// Fill in missing minutes with zero counts
|
||||
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
|
||||
$result = [];
|
||||
for ($i = $minutes - 1; $i >= 0; $i--) {
|
||||
$time = Carbon::now()->subMinutes($i);
|
||||
$minuteKey = $time->format('Y-m-d H:i:00');
|
||||
$found = $downloads->firstWhere('minute', $minuteKey);
|
||||
$found = $downloads->get($minuteKey);
|
||||
$result[] = [
|
||||
'time' => $time->format('H:i'),
|
||||
'count' => $found ? $found->count : 0,
|
||||
'count' => $found ? (int) $found->count : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -241,17 +242,18 @@ class UserStatsService
|
||||
->where('timestamp', '>=', $startTime)
|
||||
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
|
||||
->orderBy('minute', 'asc')
|
||||
->get();
|
||||
->get()
|
||||
->keyBy('minute');
|
||||
|
||||
// Fill in missing minutes with zero counts
|
||||
// Fill in missing minutes with zero counts (O(n) instead of firstWhere O(n^2))
|
||||
$result = [];
|
||||
for ($i = $minutes - 1; $i >= 0; $i--) {
|
||||
$time = Carbon::now()->subMinutes($i);
|
||||
$minuteKey = $time->format('Y-m-d H:i:00');
|
||||
$found = $apiHits->firstWhere('minute', $minuteKey);
|
||||
$found = $apiHits->get($minuteKey);
|
||||
$result[] = [
|
||||
'time' => $time->format('H:i'),
|
||||
'count' => $found ? $found->count : 0,
|
||||
'count' => $found ? (int) $found->count : 0,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -270,31 +272,36 @@ class UserStatsService
|
||||
$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();
|
||||
$sevenDaysAgo = Carbon::now()->subDays(7)->startOfDay();
|
||||
|
||||
// For weekly stats, combine aggregated historical data + live recent data
|
||||
$historicalDownloads = UserActivityStat::query()
|
||||
// Combine weekly historical totals + today/2-day live counts in a single
|
||||
// query per source table using conditional aggregation.
|
||||
$historical = UserActivityStat::query()
|
||||
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
|
||||
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
|
||||
->sum('downloads_count');
|
||||
->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits')
|
||||
->first();
|
||||
|
||||
$recentDownloads = UserDownload::query()
|
||||
$downloadAgg = UserDownload::query()
|
||||
->where('timestamp', '>=', $twoDaysAgo)
|
||||
->count();
|
||||
->selectRaw(
|
||||
'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count',
|
||||
[$today]
|
||||
)
|
||||
->first();
|
||||
|
||||
$historicalApiHits = UserActivityStat::query()
|
||||
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
|
||||
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
|
||||
->sum('api_hits_count');
|
||||
|
||||
$recentApiHits = UserRequest::query()
|
||||
$apiAgg = UserRequest::query()
|
||||
->where('timestamp', '>=', $twoDaysAgo)
|
||||
->count();
|
||||
->selectRaw(
|
||||
'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count',
|
||||
[$today]
|
||||
)
|
||||
->first();
|
||||
|
||||
return [
|
||||
'total_users' => User::whereNull('deleted_at')->count(),
|
||||
'downloads_today' => UserDownload::where('timestamp', '>=', $today)->count(),
|
||||
'downloads_week' => $historicalDownloads + $recentDownloads,
|
||||
'api_hits_today' => UserRequest::query()->where('timestamp', '>=', $today)->count(),
|
||||
'api_hits_week' => $historicalApiHits + $recentApiHits,
|
||||
'downloads_today' => (int) ($downloadAgg->today_count ?? 0),
|
||||
'downloads_week' => (int) ($historical->downloads ?? 0) + (int) ($downloadAgg->recent_count ?? 0),
|
||||
'api_hits_today' => (int) ($apiAgg->today_count ?? 0),
|
||||
'api_hits_week' => (int) ($historical->api_hits ?? 0) + (int) ($apiAgg->recent_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Returns approximate row counts for large tables using
|
||||
* information_schema.TABLES.TABLE_ROWS so we avoid COUNT(*) full scans
|
||||
* on the hottest dashboard tiles.
|
||||
*
|
||||
* Falls back to an exact COUNT(*) when:
|
||||
* - the connection isn't MySQL/MariaDB, or
|
||||
* - the approximation is unavailable / reports 0 (common for tiny tables).
|
||||
*/
|
||||
final class ApproximateRowCount
|
||||
{
|
||||
/**
|
||||
* Approximate count for a single table.
|
||||
*/
|
||||
public static function for(string $table, ?string $connection = null): int
|
||||
{
|
||||
$conn = DB::connection($connection);
|
||||
|
||||
if (! self::supportsInformationSchema($conn)) {
|
||||
return (int) $conn->table($table)->count();
|
||||
}
|
||||
|
||||
try {
|
||||
$row = $conn->selectOne(
|
||||
'SELECT TABLE_ROWS AS approx FROM information_schema.tables WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1',
|
||||
[$table]
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$row = null;
|
||||
}
|
||||
|
||||
$approx = $row?->approx ?? null;
|
||||
|
||||
if ($approx === null || (int) $approx <= 0) {
|
||||
// Fall back to exact count on tables InnoDB hasn't analyzed yet
|
||||
// or that are small enough that the approximation is meaningless.
|
||||
return (int) $conn->table($table)->count();
|
||||
}
|
||||
|
||||
return (int) $approx;
|
||||
}
|
||||
|
||||
private static function supportsInformationSchema(ConnectionInterface $conn): bool
|
||||
{
|
||||
$driver = method_exists($conn, 'getDriverName') ? $conn->getDriverName() : null;
|
||||
|
||||
return in_array($driver, ['mysql', 'mariadb'], true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user