diff --git a/app/Console/Commands/WarmAdminDashboard.php b/app/Console/Commands/WarmAdminDashboard.php new file mode 100644 index 000000000..ad63a2c6a --- /dev/null +++ b/app/Console/Commands/WarmAdminDashboard.php @@ -0,0 +1,27 @@ +warm(); + $elapsed = (int) ((microtime(true) - $start) * 1000); + + $this->info("Admin dashboard snapshot warmed in {$elapsed} ms."); + + return self::SUCCESS; + } +} + diff --git a/app/Http/Controllers/Admin/AdminPageController.php b/app/Http/Controllers/Admin/AdminPageController.php index 10beb1e1a..b4e39d961 100644 --- a/app/Http/Controllers/Admin/AdminPageController.php +++ b/app/Http/Controllers/Admin/AdminPageController.php @@ -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 + * 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 - */ - 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 - */ - 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 ]); } } + diff --git a/app/Models/UserActivity.php b/app/Models/UserActivity.php index 9a67b61d9..a0f89752b 100644 --- a/app/Models/UserActivity.php +++ b/app/Models/UserActivity.php @@ -16,11 +16,13 @@ class UserActivity extends Model 'activity_type', 'description', 'metadata', + 'is_permanent', ]; protected $casts = [ 'metadata' => 'array', 'created_at' => 'datetime', + 'is_permanent' => 'boolean', ]; /** diff --git a/app/Observers/UserActivityObserver.php b/app/Observers/UserActivityObserver.php index b27ab76b9..9d1f3cfb6 100644 --- a/app/Observers/UserActivityObserver.php +++ b/app/Observers/UserActivityObserver.php @@ -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, diff --git a/app/Services/AdminDashboardSnapshotService.php b/app/Services/AdminDashboardSnapshotService.php new file mode 100644 index 000000000..cc7cbcfcb --- /dev/null +++ b/app/Services/AdminDashboardSnapshotService.php @@ -0,0 +1,208 @@ + + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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(); + } +} + diff --git a/app/Services/SiteStatusService.php b/app/Services/SiteStatusService.php index 7ff0c8256..45a2b2e3d 100644 --- a/app/Services/SiteStatusService.php +++ b/app/Services/SiteStatusService.php @@ -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 diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index 72c6efa18..56671d565 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -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), ]; } diff --git a/app/Support/ApproximateRowCount.php b/app/Support/ApproximateRowCount.php new file mode 100644 index 000000000..5e2d9fd0c --- /dev/null +++ b/app/Support/ApproximateRowCount.php @@ -0,0 +1,59 @@ +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); + } +} + diff --git a/database/migrations/2026_04_27_000000_add_is_permanent_to_user_activities.php b/database/migrations/2026_04_27_000000_add_is_permanent_to_user_activities.php new file mode 100644 index 000000000..f3eca86bf --- /dev/null +++ b/database/migrations/2026_04_27_000000_add_is_permanent_to_user_activities.php @@ -0,0 +1,70 @@ +boolean('is_permanent')->default(false)->after('metadata'); + }); + } + + if (! $this->indexExists('user_activities', 'ix_user_activities_type_permanent')) { + Schema::table('user_activities', function (Blueprint $table): void { + $table->index(['activity_type', 'is_permanent'], 'ix_user_activities_type_permanent'); + }); + } + + // Backfill existing rows where metadata->permanent is true. + DB::table('user_activities') + ->where('activity_type', 'deleted') + ->whereRaw("JSON_EXTRACT(metadata, '$.permanent') = true") + ->update(['is_permanent' => true]); + } + + public function down(): void + { + if (! Schema::hasTable('user_activities')) { + return; + } + + if ($this->indexExists('user_activities', 'ix_user_activities_type_permanent')) { + Schema::table('user_activities', function (Blueprint $table): void { + $table->dropIndex('ix_user_activities_type_permanent'); + }); + } + + if (Schema::hasColumn('user_activities', 'is_permanent')) { + Schema::table('user_activities', function (Blueprint $table): void { + $table->dropColumn('is_permanent'); + }); + } + } + + private function indexExists(string $table, string $indexName): bool + { + try { + $rows = DB::select("SHOW INDEX FROM {$table} WHERE Key_name = ?", [$indexName]); + + return count($rows) > 0; + } catch (\Throwable) { + return false; + } + } +}; + diff --git a/resources/js/alpine/components/admin/dashboard.js b/resources/js/alpine/components/admin/dashboard.js index b6f483f68..c21207915 100644 --- a/resources/js/alpine/components/admin/dashboard.js +++ b/resources/js/alpine/components/admin/dashboard.js @@ -1,78 +1,78 @@ /** - * Alpine.data('adminDashboard') - Admin dashboard charts with widget auto-refresh. + * Alpine.data('adminDashboard') - Admin dashboard with deferred widget loading. + * + * The Blade view ships only the lightweight stat tiles + registration status + * synchronously. All heavy widgets (User Statistics, System Resources history, + * Recent Activity, Site Status, System Status) render skeleton placeholders + * and are populated by fetching the cached `admin.api.dashboard-data` payload + * on mount. The cache is warmed every minute by the scheduler so this fetch + * is essentially free. */ import Alpine from '@alpinejs/csp'; import Chart from 'chart.js/auto'; - +const STATUS_BADGE_CLASSES = { + operational: 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', + degraded: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', + maintenance: 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', + partial_outage: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + major_outage: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', +}; +const IMPACT_BADGE_CLASSES = { + critical: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', + major: 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', + minor: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', +}; +const SYSTEM_SERVICE_SLUGS = ['database', 'redis', 'queue', 'disk']; +const formatNumber = (value) => Number(value || 0).toLocaleString(); +const escapeHtml = (str) => String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +const badgeClass = (status) => 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ' + + (STATUS_BADGE_CLASSES[status] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'); Alpine.data('adminDashboard', () => ({ _charts: [], _refreshInterval: null, _visibilityHandler: null, _lastRefreshAt: Date.now(), _isRefreshing: false, - init() { - this._initCharts(); + this._loadDashboardData(); this._scheduleRefresh(); }, - _scheduleRefresh() { - const url = this.$el.dataset.refreshUrl; const interval = Number.parseInt(this.$el.dataset.refreshInterval ?? '', 10) || (15 * 60 * 1000); - - if (!url) { - return; - } - - this._refreshInterval = window.setInterval(() => this._refreshDashboard(url), interval); + this._refreshInterval = window.setInterval(() => this._loadDashboardData(), interval); this._visibilityHandler = () => { if (!document.hidden && Date.now() - this._lastRefreshAt >= interval) { - this._refreshDashboard(url); + this._loadDashboardData(); } }; - document.addEventListener('visibilitychange', this._visibilityHandler); }, - - _refreshDashboard(url) { - if (this._isRefreshing) { + _loadDashboardData() { + const url = this.$el.dataset.dataUrl; + if (!url || this._isRefreshing) { return; } - - const currentContent = this.$el.querySelector('[data-dashboard-content]'); - - if (!currentContent) { - return; - } - this._isRefreshing = true; - fetch(url, { method: 'GET', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'text/html', - }, + headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, credentials: 'same-origin', }) .then(response => { - if (!response.ok) { - throw new Error('Failed to refresh dashboard.'); - } - - return response.text(); + if (!response.ok) throw new Error('Failed to load dashboard data.'); + return response.json(); }) - .then(html => { - const parsedDocument = new DOMParser().parseFromString(html, 'text/html'); - const nextContent = parsedDocument.querySelector('#adminDashboard [data-dashboard-content]'); - - if (!nextContent) { - return; - } - - this._destroyCharts(); - currentContent.innerHTML = nextContent.innerHTML; - this._initCharts(); + .then(payload => { + this._renderUserStats(payload.userStats); + this._renderSystemMetrics(payload.systemMetrics); + this._renderRecentActivity(payload.recent_activity); + this._renderSiteStatus(payload.serviceStatuses, payload.activeIncidents); + this._renderSystemServices(payload.serviceStatuses); this._lastRefreshAt = Date.now(); }) .catch(() => {}) @@ -80,40 +80,222 @@ Alpine.data('adminDashboard', () => ({ this._isRefreshing = false; }); }, - - _initCharts() { - this._destroyCharts(); - this._initChart('downloadsChart', 'bar', 'Downloads', 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false); - this._initChart('apiHitsChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); - this._initChart('downloadsMinuteChart', 'line', 'Downloads', 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true); - this._initChart('apiHitsMinuteChart', 'line', 'API Hits', 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); - this._initPercentChart('cpuHistory24hChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); - this._initPercentChart('ramHistory24hChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); - this._initPercentChart('cpuHistory30dChart', 'CPU Usage %', 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); - this._initPercentChart('ramHistory30dChart', 'RAM Usage %', 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); + _swapWidget(name) { + const widget = this.$el.querySelector(`[data-widget="${name}"]`); + if (!widget) return null; + widget.querySelector('[data-widget-loading]')?.classList.add('hidden'); + const content = widget.querySelector('[data-widget-content]'); + content?.classList.remove('hidden'); + return widget; }, - - _parseChartData(rawData) { - try { - return JSON.parse(rawData || '[]'); - } catch { - return []; + _renderUserStats(stats) { + if (!stats) return; + const widget = this._swapWidget('user-stats'); + if (!widget) return; + const summary = stats.summary ?? {}; + const cards = [ + { label: 'Total Users', value: summary.total_users, theme: 'blue' }, + { label: 'Downloads Today', value: summary.downloads_today, theme: 'green' }, + { label: 'Downloads (7d)', value: summary.downloads_week, theme: 'purple' }, + { label: 'API Hits Today', value: summary.api_hits_today, theme: 'orange' }, + { label: 'API Hits (7d)', value: summary.api_hits_week, theme: 'pink' }, + ]; + const summaryGrid = widget.querySelector('[data-summary-grid]'); + if (summaryGrid) { + summaryGrid.innerHTML = cards.map(c => ` +
+

${escapeHtml(c.label)}

+

${formatNumber(c.value)}

+
+ `).join(''); + } + const roles = stats.users_by_role ?? []; + const totalUsers = roles.reduce((sum, r) => sum + Number(r.count || 0), 0); + const rolesTbody = widget.querySelector('[data-roles-tbody]'); + if (rolesTbody) { + rolesTbody.innerHTML = roles.length === 0 + ? 'No data available' + : roles.map(r => { + const pct = totalUsers > 0 ? ((Number(r.count) / totalUsers) * 100).toFixed(1) : '0'; + const role = String(r.role ?? ''); + const roleLabel = role ? role.charAt(0).toUpperCase() + role.slice(1) : ''; + return ` + + + ${escapeHtml(roleLabel)} + + ${formatNumber(r.count)} + ${pct}% + `; + }).join(''); + } + const downloaders = stats.top_downloaders ?? []; + const downloadersTbody = widget.querySelector('[data-downloaders-tbody]'); + if (downloadersTbody) { + const medalIcons = ['fa-medal text-yellow-500', 'fa-medal text-gray-400', 'fa-medal text-orange-600']; + downloadersTbody.innerHTML = downloaders.length === 0 + ? 'No downloads in the last 7 days' + : downloaders.map((d, i) => { + const rank = i < 3 ? `` : `${i + 1}`; + return ` + + ${rank} + ${escapeHtml(d.username)} + ${formatNumber(d.download_count)} + `; + }).join(''); + } + this._renderChart('downloadsChart', 'bar', 'Downloads', stats.downloads_per_hour ?? [], + 'rgba(34,197,94,0.7)', 'rgba(34,197,94,1)', false); + this._renderChart('apiHitsChart', 'line', 'API Hits', stats.api_hits_per_hour ?? [], + 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); + this._renderChart('downloadsMinuteChart', 'line', 'Downloads', stats.downloads_per_minute ?? [], + 'rgba(34,197,94,0.2)', 'rgba(34,197,94,1)', true); + this._renderChart('apiHitsMinuteChart', 'line', 'API Hits', stats.api_hits_per_minute ?? [], + 'rgba(168,85,247,0.2)', 'rgba(168,85,247,1)', true); + }, + _renderSystemMetrics(metrics) { + if (!metrics) return; + const widget = this._swapWidget('system-metrics'); + if (!widget) return; + const setText = (selector, text) => { + const el = widget.querySelector(`[data-metric="${selector}"]`); + if (el) el.textContent = text; + }; + const cpu = metrics.cpu ?? {}; + const ram = metrics.ram ?? {}; + const load = cpu.load_average ?? {}; + setText('cpu-current', `${Number(cpu.current ?? 0).toFixed(1)}%`); + setText('cpu-cores', cpu.cores ?? '—'); + setText('cpu-threads', cpu.threads ?? '—'); + setText('cpu-load-1min', load['1min'] ?? '—'); + setText('cpu-load-5min', load['5min'] ?? '—'); + setText('cpu-load-15min', load['15min'] ?? '—'); + const cpuModel = widget.querySelector('[data-metric="cpu-model"]'); + if (cpuModel && cpu.model && cpu.model !== 'Unknown') { + cpuModel.title = cpu.model; + cpuModel.innerHTML = `${escapeHtml(cpu.model)}`; + } + setText('ram-current', `${Number(ram.percentage ?? 0).toFixed(1)}%`); + setText('ram-details', `${Number(ram.used ?? 0).toFixed(2)} GB / ${Number(ram.total ?? 0).toFixed(2)} GB`); + this._renderPercentChart('cpuHistory24hChart', 'CPU Usage %', cpu.history_24h ?? [], + 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); + this._renderPercentChart('ramHistory24hChart', 'RAM Usage %', ram.history_24h ?? [], + 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); + this._renderPercentChart('cpuHistory30dChart', 'CPU Usage %', cpu.history_30d ?? [], + 'rgba(249,115,22,0.2)', 'rgba(249,115,22,1)'); + this._renderPercentChart('ramHistory30dChart', 'RAM Usage %', ram.history_30d ?? [], + 'rgba(6,182,212,0.2)', 'rgba(6,182,212,1)'); + }, + _renderRecentActivity(activities) { + const widget = this._swapWidget('recent-activity'); + if (!widget) return; + const updated = widget.querySelector('[data-metric="activity-updated"]'); + if (updated) { + updated.textContent = `Updated ${new Date().toLocaleTimeString()}`; + } + const container = widget.querySelector('#recent-activity-container'); + if (!container) return; + if (!Array.isArray(activities) || activities.length === 0) { + container.innerHTML = '

No recent activity

'; + return; + } + container.innerHTML = activities.map(activity => { + let extra = ''; + if (activity.type === 'deleted' && activity.metadata && activity.metadata.deleted_by) { + const permanent = activity.metadata.permanent ? ', permanent' : ''; + extra = ` (by ${escapeHtml(activity.metadata.deleted_by)}${escapeHtml(permanent)})`; + } + return ` +
+
+ +
+
+

${escapeHtml(activity.message)}${extra}

+

${escapeHtml(activity.created_at_human ?? '')}

+
+
`; + }).join(''); + }, + _renderSiteStatus(services, incidents) { + const widget = this._swapWidget('site-status'); + if (!widget) return; + const grid = widget.querySelector('[data-services-grid]'); + if (grid) { + grid.innerHTML = (services ?? []).map(svc => ` +
+
+ ${escapeHtml(svc.name)} + ${Number(svc.uptime_percentage).toFixed(2)}% uptime +
+ ${escapeHtml(svc.status_label)} +
+ `).join(''); + } + const region = widget.querySelector('[data-incidents-region]'); + if (!region) return; + if (!Array.isArray(incidents) || incidents.length === 0) { + region.innerHTML = '

No active incidents

'; + return; + } + const visible = incidents.slice(0, 5); + const overflow = incidents.length - visible.length; + const items = visible.map(inc => { + const impactClass = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full shrink-0 ' + + (IMPACT_BADGE_CLASSES[inc.impact] ?? 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'); + const autoBadge = inc.is_auto + ? 'Auto' + : ''; + const title = String(inc.title ?? ''); + const truncated = title.length > 50 ? `${title.slice(0, 50)}…` : title; + return ` +
+
+ ${escapeHtml(truncated)} + ${escapeHtml((inc.services ?? []).join(', '))} · ${escapeHtml(inc.started_at_human ?? '')}${autoBadge} +
+ ${escapeHtml(inc.impact_label)} +
`; + }).join(''); + region.innerHTML = ` +

+ ${incidents.length} Active Incident${incidents.length === 1 ? '' : 's'} +

+
${items}${overflow > 0 ? `

+ ${overflow} more

` : ''}
+ `; + }, + _renderSystemServices(services) { + const widget = this._swapWidget('system-status'); + if (!widget) return; + const container = widget.querySelector('[data-system-services]'); + if (!container) return; + const bySlug = {}; + (services ?? []).forEach(svc => { bySlug[svc.slug] = svc; }); + container.innerHTML = SYSTEM_SERVICE_SLUGS.map(slug => { + const svc = bySlug[slug]; + const label = svc?.name ?? (slug.charAt(0).toUpperCase() + slug.slice(1)); + const right = svc + ? `${escapeHtml(svc.status_label)}` + : 'Not configured'; + return ` +
+ ${escapeHtml(label)} + ${right} +
`; + }).join(''); + }, + _destroyChart(canvasId) { + const existing = this._charts.find(c => c.canvas?.id === canvasId); + if (existing) { + existing.destroy(); + this._charts = this._charts.filter(c => c !== existing); } }, - - _initChart(canvasId, type, label, backgroundColor, borderColor, fill) { + _renderChart(canvasId, type, label, data, backgroundColor, borderColor, fill) { const canvas = document.getElementById(canvasId); - - if (!canvas) { - return; - } - - const data = this._parseChartData(canvas.dataset.chartData || canvas.dataset.history); - - if (data.length === 0) { - return; - } - + if (!canvas || !Array.isArray(data) || data.length === 0) return; + this._destroyChart(canvasId); const chart = new Chart(canvas, { type, data: { @@ -131,38 +313,16 @@ Alpine.data('adminDashboard', () => ({ options: { responsive: true, maintainAspectRatio: true, - scales: { - y: { - beginAtZero: true, - ticks: { - precision: 0, - }, - }, - }, - plugins: { - legend: { - display: false, - }, - }, + scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }, + plugins: { legend: { display: false } }, }, }); - this._charts.push(chart); }, - - _initPercentChart(canvasId, label, backgroundColor, borderColor) { + _renderPercentChart(canvasId, label, data, backgroundColor, borderColor) { const canvas = document.getElementById(canvasId); - - if (!canvas) { - return; - } - - const data = this._parseChartData(canvas.dataset.history); - - if (data.length === 0) { - return; - } - + if (!canvas || !Array.isArray(data) || data.length === 0) return; + this._destroyChart(canvasId); const chart = new Chart(canvas, { type: 'line', data: { @@ -180,40 +340,19 @@ Alpine.data('adminDashboard', () => ({ options: { responsive: true, maintainAspectRatio: true, - scales: { - y: { - beginAtZero: true, - max: 100, - ticks: { - callback: value => value + '%', - }, - }, - }, - plugins: { - legend: { - display: false, - }, - }, + scales: { y: { beginAtZero: true, max: 100, ticks: { callback: value => value + '%' } } }, + plugins: { legend: { display: false } }, }, }); - this._charts.push(chart); }, - _destroyCharts() { this._charts.forEach(chart => chart.destroy()); this._charts = []; }, - destroy() { - if (this._refreshInterval) { - window.clearInterval(this._refreshInterval); - } - - if (this._visibilityHandler) { - document.removeEventListener('visibilitychange', this._visibilityHandler); - } - + if (this._refreshInterval) window.clearInterval(this._refreshInterval); + if (this._visibilityHandler) document.removeEventListener('visibilitychange', this._visibilityHandler); this._destroyCharts(); }, })); diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index f9d3284d1..21b73a1c1 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -15,6 +15,7 @@
@@ -152,8 +153,9 @@
- -
+ +

@@ -161,75 +163,15 @@ Manage →

- - @php - $dashBadge = static function (string $status): string { - $base = 'px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full'; - return $base.' '.match ($status) { - 'operational' => 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200', - 'degraded' => 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', - 'maintenance' => 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200', - 'partial_outage' => 'bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200', - 'major_outage' => 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200', - default => 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200', - }; - }; - @endphp - -
- @foreach($serviceStatuses as $svc) -
-
- {{ $svc->name }} - {{ number_format((float) $svc->uptime_percentage, 2) }}% uptime -
- {{ $svc->status->label() }} -
- @endforeach +
+
+
+
+
+ - - @if($activeIncidents->isNotEmpty()) -
-

- - {{ $activeIncidents->count() }} Active {{ Str::plural('Incident', $activeIncidents->count()) }} -

-
- @foreach($activeIncidents->take(5) as $incident) -
-
- {{ Str::limit($incident->title, 50) }} - - {{ $incident->services->pluck('name')->join(', ') }} - · {{ $incident->started_at->diffForHumans() }} - @if($incident->is_auto) - Auto - @endif - -
- {{ $incident->impact->label() }} -
- @endforeach - @if($activeIncidents->count() > 5) -

- + {{ $activeIncidents->count() - 5 }} more — - view all -

- @endif -
-
- @else -
-

- No active incidents -

-
- @endif
@@ -309,395 +251,222 @@
- - @if(isset($userStats)) -
+ +

User Statistics

- - -
-
-

Total Users

-

{{ number_format($userStats['summary']['total_users']) }}

+
+
+ @for($i = 0; $i < 5; $i++) +
+ @endfor
-
-

Downloads Today

-

{{ number_format($userStats['summary']['downloads_today']) }}

-
-
-

Downloads (7d)

-

{{ number_format($userStats['summary']['downloads_week']) }}

-
-
-

API Hits Today

-

{{ number_format($userStats['summary']['api_hits_today']) }}

-
-
-

API Hits (7d)

-

{{ number_format($userStats['summary']['api_hits_week']) }}

+
+ @for($i = 0; $i < 4; $i++) +
+ @endfor
+