diff --git a/app/Http/Controllers/Admin/AdminPageController.php b/app/Http/Controllers/Admin/AdminPageController.php index f60ad50ad..d3df3a995 100644 --- a/app/Http/Controllers/Admin/AdminPageController.php +++ b/app/Http/Controllers/Admin/AdminPageController.php @@ -60,7 +60,9 @@ class AdminPageController extends BasePageController /** * JSON payload consumed by the dashboard's deferred widgets (User Statistics, - * System Resources history, Recent Activity, Site Status). Reads from the + * System Resources history, Recent Activity, Site Status) and by the + * client-side auto-refresh which also re-renders the headline stat tiles + * and the registration status panel from this payload. Reads from the * same snapshot cache the index uses, so the warmer keeps it hot. */ public function getDashboardData(): JsonResponse @@ -99,9 +101,34 @@ class AdminPageController extends BasePageController 'metadata' => $activity->metadata, ], $payload['recent_activity']); + $registrationStatus = $payload['registrationStatus']; + $serializePeriod = static fn ($period): ?array => $period === null ? null : [ + 'id' => $period->id, + 'name' => $period->name, + 'starts_at' => $period->starts_at?->format('Y-m-d H:i'), + 'ends_at' => $period->ends_at?->format('Y-m-d H:i'), + ]; + + $registrationPayload = [ + 'manual_status' => $registrationStatus['manual_status'], + 'manual_status_label' => $registrationStatus['manual_status_label'], + 'effective_status' => $registrationStatus['effective_status'], + 'effective_status_label' => $registrationStatus['effective_status_label'], + 'scheduled_override_active' => (bool) $registrationStatus['scheduled_override_active'], + 'message' => $registrationStatus['message'], + 'active_period' => $serializePeriod($registrationStatus['active_period']), + ]; + + $generatedAt = $payload['generated_at'] ?? now()->toIso8601String(); + $generatedAtCarbon = \Illuminate\Support\Carbon::parse($generatedAt); + return response()->json([ 'success' => true, - 'generated_at' => $payload['generated_at'], + 'generated_at' => $generatedAt, + 'generated_at_time' => $generatedAtCarbon->format('H:i:s'), + 'stats' => $payload['stats'], + 'registrationStatus' => $registrationPayload, + 'nextRegistrationPeriod' => $serializePeriod($payload['nextRegistrationPeriod']), 'userStats' => $payload['userStats'], 'systemMetrics' => $payload['systemMetrics'], 'recent_activity' => $recentActivity, diff --git a/app/Http/Controllers/Admin/AdminUserController.php b/app/Http/Controllers/Admin/AdminUserController.php index b36296881..0ba372ae8 100644 --- a/app/Http/Controllers/Admin/AdminUserController.php +++ b/app/Http/Controllers/Admin/AdminUserController.php @@ -11,11 +11,13 @@ use App\Models\Invitation; use App\Models\User; use App\Models\UserDownload; use App\Models\UserRequest; +use App\Services\AdminDashboardSnapshotService; use Illuminate\Contracts\View\View; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Spatie\LaravelPasskeys\Models\Passkey; use Spatie\Permission\Models\Role; @@ -319,6 +321,7 @@ class AdminUserController extends BasePageController $username = $user->username; $user->delete(); + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); // Redirect with username to display in notification return redirect()->to('admin/user-list?deleted=1&username='.urlencode($username)); diff --git a/app/Http/Controllers/Admin/DeletedUsersController.php b/app/Http/Controllers/Admin/DeletedUsersController.php index f49f31de6..c47d61f5a 100644 --- a/app/Http/Controllers/Admin/DeletedUsersController.php +++ b/app/Http/Controllers/Admin/DeletedUsersController.php @@ -6,8 +6,10 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\User; +use App\Services\AdminDashboardSnapshotService; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class DeletedUsersController extends BasePageController { @@ -126,11 +128,13 @@ class DeletedUsersController extends BasePageController if ($action === 'restore') { $count = User::onlyTrashed()->whereIn('id', $userIds)->restore(); + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); return redirect()->route('admin.deleted.users.index')->with('success', $count.' user(s) restored successfully.'); } $count = User::onlyTrashed()->whereIn('id', $userIds)->forceDelete(); + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); return redirect()->route('admin.deleted.users.index')->with('success', $count.' user(s) permanently deleted.'); } @@ -143,6 +147,7 @@ class DeletedUsersController extends BasePageController $user = User::onlyTrashed()->find($id); if ($user) { $user->restore(); + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); return redirect()->route('admin.deleted.users.index')->with('success', "User '{$user->username}' has been restored successfully."); } @@ -159,6 +164,7 @@ class DeletedUsersController extends BasePageController if ($user) { $username = $user->username; $user->forceDelete(); + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); return redirect()->route('admin.deleted.users.index')->with('success', "User '{$username}' has been permanently deleted."); } diff --git a/app/Models/UserActivityStat.php b/app/Models/UserActivityStat.php index 8ced022b7..e3205b099 100644 --- a/app/Models/UserActivityStat.php +++ b/app/Models/UserActivityStat.php @@ -108,23 +108,45 @@ class UserActivityStat extends Model } /** - * Get total downloads for the last N days + * Get total downloads across the last N days inclusive of today. + * + * Sums closed days from `user_activity_stats` and today's closed hours + * from `user_activity_stats_hourly`. The current in-progress hour is not + * included here — callers needing live "today" totals should use + * {@see \App\Services\UserStatsService::getSummaryStats()} which layers + * the live tables on top. */ public static function getTotalDownloads(int $days = 7): int { - return self::query() - ->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d')) - ->sum('downloads_count'); + return self::sumLastNDays($days, 'downloads_count'); } /** - * Get total API hits for the last N days + * Get total API hits across the last N days inclusive of today. + * + * @see self::getTotalDownloads() for source-layering notes. */ public static function getTotalApiHits(int $days = 7): int { - return self::query() - ->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d')) - ->sum('api_hits_count'); + return self::sumLastNDays($days, 'api_hits_count'); + } + + private static function sumLastNDays(int $days, string $column): int + { + $today = Carbon::now()->startOfDay(); + $weekStart = Carbon::now()->subDays($days - 1)->startOfDay(); + + $closedDays = (int) self::query() + ->where('stat_date', '>=', $weekStart->format('Y-m-d')) + ->where('stat_date', '<', $today->format('Y-m-d')) + ->sum($column); + + $todayClosedHours = (int) DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $today->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', Carbon::now()->startOfHour()->format('Y-m-d H:00:00')) + ->sum($column); + + return $closedDays + $todayClosedHours; } /** diff --git a/app/Services/RegistrationStatusService.php b/app/Services/RegistrationStatusService.php index 78642f70a..d2cbf812e 100644 --- a/app/Services/RegistrationStatusService.php +++ b/app/Services/RegistrationStatusService.php @@ -10,6 +10,7 @@ use App\Models\Settings; use App\Models\User; use Carbon\CarbonInterface; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Cache; class RegistrationStatusService { @@ -117,6 +118,8 @@ class RegistrationStatusService 'registerstatus' => $newStatus, ]); + $this->forgetDashboardSnapshot(); + if ($oldStatus === $newStatus && blank($note)) { return; } @@ -169,6 +172,8 @@ class RegistrationStatusService ] ); + $this->forgetDashboardSnapshot(); + return $period; } @@ -202,6 +207,8 @@ class RegistrationStatusService ] ); + $this->forgetDashboardSnapshot(); + return $period; } @@ -229,6 +236,8 @@ class RegistrationStatusService ] ); + $this->forgetDashboardSnapshot(); + return $period; } @@ -264,6 +273,10 @@ class RegistrationStatusService ); } + if ($expiredPeriods->isNotEmpty()) { + $this->forgetDashboardSnapshot(); + } + return $expiredPeriods; } @@ -286,6 +299,8 @@ class RegistrationStatusService 'period' => $snapshot, ] ); + + $this->forgetDashboardSnapshot(); } private function buildMessage( @@ -324,4 +339,14 @@ class RegistrationStatusService 'updated_by' => $period->updated_by, ]; } + + /** + * Drop the cached admin dashboard snapshot so the per-minute warmer (or + * the next admin request) rebuilds with fresh registration data. Avoids + * an inline rebuild here to keep write-path latency low. + */ + private function forgetDashboardSnapshot(): void + { + Cache::forget(AdminDashboardSnapshotService::CACHE_KEY); + } } diff --git a/app/Services/UserStatsService.php b/app/Services/UserStatsService.php index 56671d565..d7c730f25 100644 --- a/app/Services/UserStatsService.php +++ b/app/Services/UserStatsService.php @@ -36,68 +36,25 @@ class UserStatsService } /** - * Get downloads per day for the last N days - * Uses aggregated stats from user_activity_stats table for dates older than 2 days - * Uses live data from user_downloads table for recent days + * Get downloads per day for the last N days (inclusive of today). * - * @return array + * Source layering — designed to avoid the 1-day rolling purge of + * `user_downloads` (see routes/console.php cleanup-api-request-logs): + * - Closed days (today-(N-1) … yesterday) come from `user_activity_stats`. + * - Today is composed of closed hours from `user_activity_stats_hourly` + * + the in-progress hour from the live `user_downloads` table. + * + * @return list> */ public function getDownloadsPerDay(int $days = 7): array { - $startDate = Carbon::now()->subDays($days - 1)->startOfDay(); - $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); - - $result = []; - - // For historical data (older than 2 days), use aggregated stats - if ($days > 2) { - $historicalStartDate = $startDate->format('Y-m-d'); - $historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d'); - - $historicalStats = UserActivityStat::query() - ->select('stat_date', 'downloads_count') - ->where('stat_date', '>=', $historicalStartDate) - ->where('stat_date', '<=', $historicalEndDate) - ->orderBy('stat_date', 'asc') - ->get() - ->keyBy('stat_date'); - - // Add historical data - $currentDate = $startDate->copy(); - while ($currentDate->lt($twoDaysAgo)) { - $dateStr = $currentDate->format('Y-m-d'); - $stat = $historicalStats->get($dateStr); - $result[] = [ - 'date' => $currentDate->format('M d'), - 'count' => $stat ? $stat->downloads_count : 0, - ]; - $currentDate->addDay(); - } - } - - // For recent data (last 2 days), use live data from user_downloads - $downloads = UserDownload::query() - ->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count')) - ->where('timestamp', '>=', $twoDaysAgo) - ->groupBy(DB::raw('DATE(timestamp)')) - ->orderBy('date', 'asc') - ->get() - ->keyBy('date'); - - // Add recent data - $currentDate = $twoDaysAgo->copy(); - $now = Carbon::now(); - while ($currentDate->lte($now)) { - $dateStr = $currentDate->format('Y-m-d'); - $found = $downloads->get($dateStr); - $result[] = [ - 'date' => $currentDate->format('M d'), - 'count' => $found ? $found->count : 0, - ]; - $currentDate->addDay(); - } - - return $result; + return $this->buildPerDaySeries( + $days, + 'downloads_count', + static fn (Carbon $from): int => UserDownload::query() + ->where('timestamp', '>=', $from) + ->count() + ); } /** @@ -148,68 +105,23 @@ class UserStatsService } /** - * Get API hits per day for the last N days - * Uses aggregated stats from user_activity_stats table for dates older than 2 days - * Uses live data from user_requests table for recent days + * Get API hits per day for the last N days (inclusive of today). * - * @return list> + * Mirrors {@see self::getDownloadsPerDay()} — closed days from + * `user_activity_stats`, today composed of `user_activity_stats_hourly` + * + the in-progress hour from `user_requests`. + * + * @return list> */ public function getApiHitsPerDay(int $days = 7): array { - $startDate = Carbon::now()->subDays($days - 1)->startOfDay(); - $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); - - $result = []; - - // For historical data (older than 2 days), use aggregated stats - if ($days > 2) { - $historicalStartDate = $startDate->format('Y-m-d'); - $historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d'); - - $historicalStats = UserActivityStat::query() - ->select('stat_date', 'api_hits_count') - ->where('stat_date', '>=', $historicalStartDate) - ->where('stat_date', '<=', $historicalEndDate) - ->orderBy('stat_date', 'asc') - ->get() - ->keyBy('stat_date'); - - // Add historical data - $currentDate = $startDate->copy(); - while ($currentDate->lt($twoDaysAgo)) { - $dateStr = $currentDate->format('Y-m-d'); - $stat = $historicalStats->get($dateStr); - $result[] = [ - 'date' => $currentDate->format('M d'), - 'count' => $stat ? $stat->api_hits_count : 0, - ]; - $currentDate->addDay(); - } - } - - // For recent data (last 2 days), use live data from user_requests - $apiHits = UserRequest::query() - ->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count')) - ->where('timestamp', '>=', $twoDaysAgo) - ->groupBy(DB::raw('DATE(timestamp)')) - ->orderBy('date', 'asc') - ->get() - ->keyBy('date'); - - // Add recent data - $currentDate = $twoDaysAgo->copy(); - $now = Carbon::now(); - while ($currentDate->lte($now)) { - $dateStr = $currentDate->format('Y-m-d'); - $found = $apiHits->get($dateStr); - $result[] = [ - 'date' => $currentDate->format('M d'), - 'count' => $found ? $found->count : 0, - ]; - $currentDate->addDay(); - } - - return $result; + return $this->buildPerDaySeries( + $days, + 'api_hits_count', + static fn (Carbon $from): int => UserRequest::query() + ->where('timestamp', '>=', $from) + ->count() + ); } /** @@ -261,47 +173,56 @@ class UserStatsService } /** - * Get summary statistics - * Uses aggregated stats for weekly totals where possible + * Get summary statistics for the headline tiles. * - * @return list> + * Window: "today" and "(7d) = last 7 calendar days inclusive of today". + * + * The live `user_downloads` / `user_requests` tables are pruned hourly to + * the last ~24h (see routes/console.php), so we layer sources to keep the + * totals consistent regardless of when the purge ran: + * - Closed days (today-6 … yesterday) → `user_activity_stats`. + * - Today's closed hours → `user_activity_stats_hourly`. + * - Current in-progress hour → live `user_downloads` / `user_requests`. + * + * @return array{total_users: int, downloads_today: int, downloads_week: int, api_hits_today: int, api_hits_week: int} */ public function getSummaryStats(): array { $today = Carbon::now()->startOfDay(); - $twoDaysAgo = Carbon::now()->subDays(2)->startOfDay(); - $sevenDaysAgo = Carbon::now()->subDays(7)->startOfDay(); + $weekStart = Carbon::now()->subDays(6)->startOfDay(); + $currentHourStart = Carbon::now()->startOfHour(); - // Combine weekly historical totals + today/2-day live counts in a single - // query per source table using conditional aggregation. + // Closed days: [today-6 .. yesterday] $historical = UserActivityStat::query() - ->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d')) - ->where('stat_date', '<', $twoDaysAgo->format('Y-m-d')) + ->where('stat_date', '>=', $weekStart->format('Y-m-d')) + ->where('stat_date', '<', $today->format('Y-m-d')) ->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits') ->first(); - $downloadAgg = UserDownload::query() - ->where('timestamp', '>=', $twoDaysAgo) - ->selectRaw( - 'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count', - [$today] - ) + // Today: closed hours from hourly aggregate. + $todayClosed = DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $today->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00')) + ->selectRaw('COALESCE(SUM(downloads_count), 0) as downloads, COALESCE(SUM(api_hits_count), 0) as api_hits') ->first(); - $apiAgg = UserRequest::query() - ->where('timestamp', '>=', $twoDaysAgo) - ->selectRaw( - 'SUM(CASE WHEN timestamp >= ? THEN 1 ELSE 0 END) as today_count, COUNT(*) as recent_count', - [$today] - ) - ->first(); + // Current in-progress hour from live tables. + $downloadsCurrentHour = UserDownload::query() + ->where('timestamp', '>=', $currentHourStart) + ->count(); + $apiHitsCurrentHour = UserRequest::query() + ->where('timestamp', '>=', $currentHourStart) + ->count(); + + $downloadsToday = (int) ($todayClosed->downloads ?? 0) + $downloadsCurrentHour; + $apiHitsToday = (int) ($todayClosed->api_hits ?? 0) + $apiHitsCurrentHour; return [ 'total_users' => User::whereNull('deleted_at')->count(), - '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), + 'downloads_today' => $downloadsToday, + 'downloads_week' => (int) ($historical->downloads ?? 0) + $downloadsToday, + 'api_hits_today' => $apiHitsToday, + 'api_hits_week' => (int) ($historical->api_hits ?? 0) + $apiHitsToday, ]; } @@ -324,4 +245,57 @@ class UserStatsService ->get() ->toArray(); } + + /** + * Build a "last N days inclusive of today" series, layering closed days + * (from `user_activity_stats`), today's closed hours (from + * `user_activity_stats_hourly`) and the current in-progress hour from a + * caller-supplied live counter. + * + * @param callable(Carbon): int $liveCurrentHourCounter + * @return list + */ + private function buildPerDaySeries(int $days, string $statColumn, callable $liveCurrentHourCounter): array + { + $today = Carbon::now()->startOfDay(); + $startDate = $today->copy()->subDays($days - 1); + + $historical = UserActivityStat::query() + ->select('stat_date', $statColumn) + ->where('stat_date', '>=', $startDate->format('Y-m-d')) + ->where('stat_date', '<', $today->format('Y-m-d')) + ->orderBy('stat_date', 'asc') + ->get() + ->keyBy(static fn (UserActivityStat $s): string => $s->stat_date instanceof Carbon + ? $s->stat_date->format('Y-m-d') + : (string) $s->stat_date); + + $result = []; + $cursor = $startDate->copy(); + while ($cursor->lt($today)) { + $stat = $historical->get($cursor->format('Y-m-d')); + $result[] = [ + 'date' => $cursor->format('M d'), + 'count' => $stat ? (int) $stat->{$statColumn} : 0, + ]; + $cursor->addDay(); + } + + // Today: closed hours from hourly aggregate + the current hour from live. + $currentHourStart = Carbon::now()->startOfHour(); + $hourlyColumn = $statColumn === 'api_hits_count' ? 'api_hits_count' : 'downloads_count'; + $todayClosed = (int) DB::table('user_activity_stats_hourly') + ->where('stat_hour', '>=', $today->format('Y-m-d H:00:00')) + ->where('stat_hour', '<', $currentHourStart->format('Y-m-d H:00:00')) + ->sum($hourlyColumn); + + $todayLive = (int) $liveCurrentHourCounter($currentHourStart); + + $result[] = [ + 'date' => $today->format('M d'), + 'count' => $todayClosed + $todayLive, + ]; + + return $result; + } } diff --git a/resources/js/alpine/components/admin/dashboard.js b/resources/js/alpine/components/admin/dashboard.js index c21207915..334bb4b2e 100644 --- a/resources/js/alpine/components/admin/dashboard.js +++ b/resources/js/alpine/components/admin/dashboard.js @@ -23,6 +23,13 @@ const IMPACT_BADGE_CLASSES = { minor: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200', }; const SYSTEM_SERVICE_SLUGS = ['database', 'redis', 'queue', 'disk']; +// Mirror of \App\Models\Settings::REGISTER_STATUS_*. +const REGISTRATION_BADGE_BASE = 'inline-flex items-center justify-center rounded-full px-3 py-1.5 text-sm font-semibold '; +const REGISTRATION_BADGE_CLASSES = { + 0: 'border border-emerald-500/30 bg-emerald-600 text-white shadow-sm dark:border-emerald-300/20 dark:bg-emerald-500 dark:text-slate-950', + 1: 'border border-amber-500/30 bg-amber-500 text-slate-950 shadow-sm dark:border-amber-200/20 dark:bg-amber-400 dark:text-slate-950', + 2: 'border border-rose-500/30 bg-rose-600 text-white shadow-sm dark:border-rose-200/20 dark:bg-rose-500 dark:text-white', +}; const formatNumber = (value) => Number(value || 0).toLocaleString(); const escapeHtml = (str) => String(str ?? '') .replace(/&/g, '&') @@ -32,6 +39,8 @@ const escapeHtml = (str) => String(str ?? '') .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'); +const registrationBadgeClass = (status) => REGISTRATION_BADGE_BASE + + (REGISTRATION_BADGE_CLASSES[status] ?? REGISTRATION_BADGE_CLASSES[2]); Alpine.data('adminDashboard', () => ({ _charts: [], _refreshInterval: null, @@ -68,6 +77,9 @@ Alpine.data('adminDashboard', () => ({ return response.json(); }) .then(payload => { + this._renderHeadlineStats(payload.stats); + this._renderRegistrationStatus(payload.registrationStatus); + this._renderLastRefresh(payload.generated_at_time); this._renderUserStats(payload.userStats); this._renderSystemMetrics(payload.systemMetrics); this._renderRecentActivity(payload.recent_activity); @@ -88,6 +100,55 @@ Alpine.data('adminDashboard', () => ({ content?.classList.remove('hidden'); return widget; }, + _setStatText(key, value) { + const el = this.$el.querySelector(`[data-stat="${key}"]`); + if (el) el.textContent = value; + }, + _renderHeadlineStats(stats) { + if (!stats) return; + const soft = Number(stats.soft_deleted_users ?? 0); + const perm = Number(stats.permanently_deleted_users ?? 0); + this._setStatText('releases', formatNumber(stats.releases)); + this._setStatText('releases-today', formatNumber(stats.releases_today)); + this._setStatText('users', formatNumber(stats.users)); + this._setStatText('users-today', formatNumber(stats.users_today)); + this._setStatText('active-groups', formatNumber(stats.active_groups)); + this._setStatText('groups', formatNumber(stats.groups)); + this._setStatText('failed', formatNumber(stats.failed)); + this._setStatText('reported', formatNumber(stats.reported)); + this._setStatText('soft-deleted-users', formatNumber(soft)); + this._setStatText('permanently-deleted-users', formatNumber(perm)); + this._setStatText('deleted-users-total', formatNumber(soft + perm)); + }, + _renderRegistrationStatus(reg) { + if (!reg) return; + const effective = this.$el.querySelector('[data-registration="effective-badge"]'); + if (effective) { + effective.className = registrationBadgeClass(Number(reg.effective_status)); + effective.textContent = String(reg.effective_status_label ?? ''); + } + const manual = this.$el.querySelector('[data-registration="manual-badge"]'); + if (manual) { + manual.className = registrationBadgeClass(Number(reg.manual_status)); + manual.textContent = String(reg.manual_status_label ?? ''); + } + const override = this.$el.querySelector('[data-registration="scheduled-override"]'); + if (override) { + override.classList.toggle('hidden', !reg.scheduled_override_active); + } + const message = this.$el.querySelector('[data-registration="message"]'); + if (message) message.textContent = String(reg.message ?? ''); + }, + _renderLastRefresh(timeText) { + // "Last dashboard refresh" reflects when the JS last successfully + // fetched data — drive it from the browser clock so the indicator + // always advances on a successful tick, even if the server snapshot + // happened to be served from cache (Cache::flexible) with an older + // `generated_at`. The server timestamp (when supplied) is used as a + // fallback only — see AdminPageController::getDashboardData(). + const clientNow = new Date().toLocaleTimeString(); + this._setStatText('last-refresh', clientNow || timeText || ''); + }, _renderUserStats(stats) { if (!stats) return; const widget = this._swapWidget('user-stats'); @@ -96,9 +157,9 @@ Alpine.data('adminDashboard', () => ({ 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: 'Downloads (last 7d incl. today)', 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' }, + { label: 'API Hits (last 7d incl. today)', value: summary.api_hits_week, theme: 'pink' }, ]; const summaryGrid = widget.querySelector('[data-summary-grid]'); if (summaryGrid) { diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 21b73a1c1..627823d5f 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -14,7 +14,6 @@
@@ -27,7 +26,7 @@

- Last dashboard refresh: {{ $dashboardLastRefreshedAt }} + Last dashboard refresh: {{ $dashboardLastRefreshedAt }}

Auto-refreshes every 15 minutes

@@ -41,7 +40,7 @@

Total Releases

-

{{ number_format($stats['releases'] ?? 0) }}

+

{{ number_format($stats['releases'] ?? 0) }}

@@ -49,7 +48,7 @@
- {{ number_format($stats['releases_today'] ?? 0) }} today + {{ number_format($stats['releases_today'] ?? 0) }} today
@@ -59,7 +58,7 @@

Active Users

-

{{ number_format($stats['users'] ?? 0) }}

+

{{ number_format($stats['users'] ?? 0) }}

@@ -67,7 +66,7 @@
- {{ $stats['users_today'] ?? 0 }} registered today + {{ $stats['users_today'] ?? 0 }} registered today
@@ -77,7 +76,7 @@

Active Groups

-

{{ number_format($stats['active_groups'] ?? 0) }}

+

{{ number_format($stats['active_groups'] ?? 0) }}

@@ -85,7 +84,7 @@
- {{ number_format($stats['groups'] ?? 0) }} total groups + {{ number_format($stats['groups'] ?? 0) }} total groups
@@ -95,7 +94,7 @@

Failed Releases

-

{{ number_format($stats['failed'] ?? 0) }}

+

{{ number_format($stats['failed'] ?? 0) }}

@@ -113,7 +112,7 @@

Reported Releases

-

{{ number_format($stats['reported'] ?? 0) }}

+

{{ number_format($stats['reported'] ?? 0) }}

@@ -131,7 +130,7 @@

Deleted Users

-

{{ number_format(($stats['soft_deleted_users'] ?? 0) + ($stats['permanently_deleted_users'] ?? 0)) }}

+

{{ number_format(($stats['soft_deleted_users'] ?? 0) + ($stats['permanently_deleted_users'] ?? 0)) }}

@@ -139,10 +138,10 @@
- {{ number_format($stats['soft_deleted_users'] ?? 0) }} soft deleted + {{ number_format($stats['soft_deleted_users'] ?? 0) }} soft deleted - {{ number_format($stats['permanently_deleted_users'] ?? 0) }} permanently deleted + {{ number_format($stats['permanently_deleted_users'] ?? 0) }} permanently deleted
@@ -175,7 +174,8 @@
-
+

@@ -196,22 +196,26 @@

Effective Status

- + {{ $registrationStatus['effective_status_label'] }} - @if($registrationStatus['scheduled_override_active']) - - Scheduled Override - - @endif + ! $registrationStatus['scheduled_override_active'], + ])> + Scheduled Override +
-

{{ $registrationStatus['message'] }}

+

{{ $registrationStatus['message'] }}

Manual Baseline

- + {{ $registrationStatus['manual_status_label'] }}
diff --git a/routes/console.php b/routes/console.php index 2f7add235..3a53221b3 100644 --- a/routes/console.php +++ b/routes/console.php @@ -34,7 +34,7 @@ Schedule::command('nntmux:disable-expired-registration-periods')->everyFiveMinut Schedule::command('nntmux:remove-bad')->hourly()->withoutOverlapping(); Schedule::command('cloudflare:reload')->daily()->withoutOverlapping(); Schedule::command('cache:prune-stale-tags')->hourly(); -Schedule::command('nntmux:collect-stats')->hourly(); +Schedule::command('nntmux:collect-stats')->everyFifteenMinutes()->withoutOverlapping(); Schedule::command('nntmux:populate-steam-apps')->monthly(); // Collect system metrics every 5 minutes Schedule::command('metrics:collect')->everyFiveMinutes()->withoutOverlapping(); diff --git a/tests/Feature/AdminDashboardAutoRefreshTest.php b/tests/Feature/AdminDashboardAutoRefreshTest.php index 8f371009b..d57abb82b 100644 --- a/tests/Feature/AdminDashboardAutoRefreshTest.php +++ b/tests/Feature/AdminDashboardAutoRefreshTest.php @@ -15,9 +15,12 @@ class AdminDashboardAutoRefreshTest extends TestCase $content = file_get_contents($bladePath); $this->assertStringContainsString('x-data="adminDashboard"', $content); - $this->assertStringContainsString("data-refresh-url=\"{{ route('admin.index') }}\"", $content); + $this->assertStringContainsString('data-data-url="{{ route(\'admin.api.dashboard-data\') }}"', $content); $this->assertStringContainsString('data-refresh-interval="{{ 15 * 60 * 1000 }}"', $content); $this->assertStringContainsString('data-dashboard-content', $content); + // The previous full-page-reload approach has been removed in favour of + // updating headline tiles + widgets directly from the JSON payload. + $this->assertStringNotContainsString('data-refresh-url=', $content); } public function test_dashboard_blade_labels_match_fifteen_minute_refresh(): void @@ -31,12 +34,12 @@ class AdminDashboardAutoRefreshTest extends TestCase $this->assertStringContainsString('Auto-refreshes every 15 minutes', $content); $this->assertStringContainsString('Last dashboard refresh:', $content); $this->assertStringContainsString('$dashboardLastRefreshedAt', $content); - $this->assertStringNotContainsString('@php($dashboardLastRefreshedAt', $content); + $this->assertStringContainsString('data-stat="last-refresh"', $content); $this->assertStringNotContainsString('Auto-refreshes every 20 minutes', $content); $this->assertStringNotContainsString('Auto-updates every minute', $content); } - public function test_dashboard_component_refreshes_widgets_without_page_reload(): void + public function test_dashboard_component_refreshes_headline_stats_without_page_reload(): void { $scriptPath = resource_path('js/alpine/components/admin/dashboard.js'); @@ -44,10 +47,54 @@ class AdminDashboardAutoRefreshTest extends TestCase $content = file_get_contents($scriptPath); - $this->assertStringContainsString('_refreshDashboard(url)', $content); - $this->assertStringContainsString('DOMParser().parseFromString', $content); - $this->assertStringContainsString('#adminDashboard [data-dashboard-content]', $content); - $this->assertStringContainsString('currentContent.innerHTML = nextContent.innerHTML', $content); + // Auto-refresh interval is preserved. $this->assertStringContainsString('15 * 60 * 1000', $content); + // The fetch hits the cached JSON endpoint. + $this->assertStringContainsString("this.\$el.dataset.dataUrl", $content); + // Each tick re-renders headline tiles + registration status + the + // visible "Last refresh" label, not just the deferred widgets. + $this->assertStringContainsString('_renderHeadlineStats(payload.stats)', $content); + $this->assertStringContainsString('_renderRegistrationStatus(payload.registrationStatus)', $content); + $this->assertStringContainsString('_renderLastRefresh(payload.generated_at_time)', $content); + } + + public function test_dashboard_blade_exposes_stat_hooks_for_each_headline_tile(): void + { + $bladePath = resource_path('views/admin/dashboard.blade.php'); + $content = (string) file_get_contents($bladePath); + + foreach ([ + 'releases', + 'releases-today', + 'users', + 'users-today', + 'active-groups', + 'groups', + 'failed', + 'reported', + 'soft-deleted-users', + 'permanently-deleted-users', + 'deleted-users-total', + 'last-refresh', + ] as $hook) { + $this->assertStringContainsString( + 'data-stat="'.$hook.'"', + $content, + "Missing data-stat=\"$hook\" hook required by the auto-refresh JS." + ); + } + + foreach ([ + 'effective-badge', + 'manual-badge', + 'scheduled-override', + 'message', + ] as $hook) { + $this->assertStringContainsString( + 'data-registration="'.$hook.'"', + $content, + "Missing data-registration=\"$hook\" hook required by the auto-refresh JS." + ); + } } } diff --git a/tests/Feature/AdminDashboardRegistrationStatusWidgetTest.php b/tests/Feature/AdminDashboardRegistrationStatusWidgetTest.php index a9d7da339..e9f591dd5 100644 --- a/tests/Feature/AdminDashboardRegistrationStatusWidgetTest.php +++ b/tests/Feature/AdminDashboardRegistrationStatusWidgetTest.php @@ -24,15 +24,28 @@ class AdminDashboardRegistrationStatusWidgetTest extends TestCase public function test_dashboard_controller_provides_registration_widget_data(): void { + // The dashboard view receives `registrationStatus` / `nextRegistrationPeriod` + // built by AdminDashboardSnapshotService, and the auto-refresh JSON + // endpoint serialises the same payload for the client. + $snapshotPath = app_path('Services/AdminDashboardSnapshotService.php'); $controllerPath = app_path('Http/Controllers/Admin/AdminPageController.php'); + $this->assertFileExists($snapshotPath); $this->assertFileExists($controllerPath); - $content = file_get_contents($controllerPath); + $snapshotContent = (string) file_get_contents($snapshotPath); + $controllerContent = (string) file_get_contents($controllerPath); - $this->assertStringContainsString('RegistrationStatusService', $content); - $this->assertStringContainsString("'registrationStatus' => \$registrationStatus", $content); - $this->assertStringContainsString("'nextRegistrationPeriod' => \$nextRegistrationPeriod", $content); - $this->assertStringContainsString('getNextUpcomingPeriod', $content); + // Snapshot builds the registration data used by the widget. + $this->assertStringContainsString('RegistrationStatusService', $snapshotContent); + $this->assertStringContainsString("'registrationStatus' => \$registrationStatus", $snapshotContent); + $this->assertStringContainsString("'nextRegistrationPeriod' => \$nextRegistrationPeriod", $snapshotContent); + $this->assertStringContainsString('getNextUpcomingPeriod', $snapshotContent); + + // Controller passes the snapshot fields through to the Blade view + // and the JSON endpoint consumed by the auto-refresh JS. + $this->assertStringContainsString("'registrationStatus' => \$payload['registrationStatus']", $controllerContent); + $this->assertStringContainsString("'nextRegistrationPeriod' => \$payload['nextRegistrationPeriod']", $controllerContent); + $this->assertStringContainsString("'registrationStatus' => \$registrationPayload", $controllerContent); } } diff --git a/tests/Feature/AdminDashboardReportedReleasesTest.php b/tests/Feature/AdminDashboardReportedReleasesTest.php index 97e4e385d..7818465e8 100644 --- a/tests/Feature/AdminDashboardReportedReleasesTest.php +++ b/tests/Feature/AdminDashboardReportedReleasesTest.php @@ -25,19 +25,28 @@ class AdminDashboardReportedReleasesTest extends TestCase } /** - * Test that the AdminPageController contains the reported count in stats. + * Test that the dashboard snapshot exposes the reported-releases count. + * + * The "stats" payload is built by AdminDashboardSnapshotService and + * surfaced to the Blade view (and to the auto-refresh JSON endpoint) + * through AdminPageController, so the assertions live on the service. */ public function test_controller_stats_method_has_reported_key(): void { - $controllerPath = app_path('Http/Controllers/Admin/AdminPageController.php'); + $servicePath = app_path('Services/AdminDashboardSnapshotService.php'); - $this->assertFileExists($controllerPath); + $this->assertFileExists($servicePath); - $content = file_get_contents($controllerPath); + $content = (string) file_get_contents($servicePath); - // Check that the controller contains the reported releases count $this->assertStringContainsString("'reported'", $content); - $this->assertStringContainsString('admin_stats_reported_count', $content); - $this->assertStringContainsString('ReleaseReport::where', $content); + $this->assertStringContainsString('ReleaseReport::', $content); + + $controllerPath = app_path('Http/Controllers/Admin/AdminPageController.php'); + $controllerContent = (string) file_get_contents($controllerPath); + + // Controller still surfaces the snapshot stats payload to the view + // and the JSON endpoint used by the auto-refresh JS. + $this->assertStringContainsString("'stats' => \$payload['stats']", $controllerContent); } } diff --git a/tests/Feature/AdminDashboardSummaryWindowTest.php b/tests/Feature/AdminDashboardSummaryWindowTest.php new file mode 100644 index 000000000..5fe57050c --- /dev/null +++ b/tests/Feature/AdminDashboardSummaryWindowTest.php @@ -0,0 +1,168 @@ +assertFileExists($servicePath); + + $content = (string) file_get_contents($servicePath); + + // 7-day window inclusive of today (today-6 .. today). + $this->assertStringContainsString( + "\$weekStart = Carbon::now()->subDays(6)->startOfDay();", + $content, + 'Summary window must be the last 7 calendar days inclusive of today.' + ); + + // Closed days from the daily aggregate, strictly before today. + $this->assertStringContainsString("UserActivityStat::query()", $content); + $this->assertStringContainsString( + "->where('stat_date', '>=', \$weekStart->format('Y-m-d'))", + $content + ); + $this->assertStringContainsString( + "->where('stat_date', '<', \$today->format('Y-m-d'))", + $content + ); + + // Today's closed hours from the hourly aggregate. + $this->assertStringContainsString( + "DB::table('user_activity_stats_hourly')", + $content + ); + $this->assertStringContainsString( + "->where('stat_hour', '<', \$currentHourStart->format('Y-m-d H:00:00'))", + $content + ); + + // Current in-progress hour from the live tables. + $this->assertStringContainsString( + "UserDownload::query()\n ->where('timestamp', '>=', \$currentHourStart)", + $content + ); + $this->assertStringContainsString( + "UserRequest::query()\n ->where('timestamp', '>=', \$currentHourStart)", + $content + ); + + // Today and week totals must combine all three sources. + $this->assertStringContainsString( + "'downloads_today' => \$downloadsToday,", + $content + ); + $this->assertStringContainsString( + "'downloads_week' => (int) (\$historical->downloads ?? 0) + \$downloadsToday,", + $content + ); + $this->assertStringContainsString( + "'api_hits_today' => \$apiHitsToday,", + $content + ); + $this->assertStringContainsString( + "'api_hits_week' => (int) (\$historical->api_hits ?? 0) + \$apiHitsToday,", + $content + ); + + // The previous "two-days-ago" boundary that produced the under-reporting + // bug must be gone from getSummaryStats. + $this->assertStringNotContainsString( + "\$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();", + $content + ); + } + + public function test_per_day_series_uses_same_source_layering(): void + { + $servicePath = app_path('Services/UserStatsService.php'); + $content = (string) file_get_contents($servicePath); + + // Both per-day series helpers must delegate to the shared builder so + // the chart and headline summary stay consistent. + $this->assertStringContainsString( + 'private function buildPerDaySeries(int $days, string $statColumn, callable $liveCurrentHourCounter): array', + $content + ); + $this->assertStringContainsString( + "return \$this->buildPerDaySeries(\n \$days,\n 'downloads_count',", + $content + ); + $this->assertStringContainsString( + "return \$this->buildPerDaySeries(\n \$days,\n 'api_hits_count',", + $content + ); + } + + public function test_dashboard_data_endpoint_returns_stats_and_registration(): void + { + $controllerPath = app_path('Http/Controllers/Admin/AdminPageController.php'); + + $this->assertFileExists($controllerPath); + + $content = (string) file_get_contents($controllerPath); + + // Auto-refresh JS depends on these payload keys to update the + // headline tiles and registration panel without a page reload. + $this->assertStringContainsString("'stats' => \$payload['stats'],", $content); + $this->assertStringContainsString("'registrationStatus' => \$registrationPayload,", $content); + $this->assertStringContainsString("'nextRegistrationPeriod' => \$serializePeriod(\$payload['nextRegistrationPeriod']),", $content); + $this->assertStringContainsString("'generated_at_time' => \$generatedAtCarbon->format('H:i:s'),", $content); + } + + public function test_collect_stats_runs_every_fifteen_minutes(): void + { + $consolePath = base_path('routes/console.php'); + + $this->assertFileExists($consolePath); + + $content = (string) file_get_contents($consolePath); + + $this->assertStringContainsString( + "Schedule::command('nntmux:collect-stats')->everyFifteenMinutes()", + $content, + 'The user activity stats collector should run every 15 minutes so ' + .'the hourly aggregate catches up shortly after each hour boundary.' + ); + } + + public function test_registration_writes_invalidate_dashboard_snapshot(): void + { + $servicePath = app_path('Services/RegistrationStatusService.php'); + $content = (string) file_get_contents($servicePath); + + $this->assertStringContainsString('use Illuminate\\Support\\Facades\\Cache;', $content); + $this->assertStringContainsString( + 'Cache::forget(AdminDashboardSnapshotService::CACHE_KEY);', + $content + ); + // Each public write path drops the snapshot. + $this->assertSame( + 6, + substr_count($content, '$this->forgetDashboardSnapshot();'), + 'updateManualStatus, createPeriod, updatePeriod, togglePeriod, ' + .'disableExpiredPeriods and deletePeriod must all forget the dashboard snapshot.' + ); + } +} +