subDays($days - 1)->startOfDay(); return [ 'kpis' => [ 'total_users' => User::count(), 'active_sessions' => Token::query()->where('revoked', false)->where('expires_at', '>', now())->count(), 'logins_today' => LoginEvent::successful()->whereDate('created_at', today())->count(), 'logins_7d' => LoginEvent::successful()->where('created_at', '>=', now()->subDays(7))->count(), 'login_failures_7d' => LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', now()->subDays(7))->count(), 'login_failure_rate_7d' => self::loginFailureRate(now()->subDays(7)), ], 'logins_by_day' => self::loginsByDay($since, $days), 'logins_by_app' => self::loginsByApp($since), 'top_users' => self::topUsers($since), 'recent' => self::recent(), 'oauth_funnel' => self::oauthFunnel($since), 'oauth_funnel_by_client' => self::oauthFunnelByClient($since), 'active_tokens_by_client' => self::activeTokensByClient(), 'scope_usage' => self::scopeUsage($since), ]; } private static function loginFailureRate(Carbon $since): float { $success = LoginEvent::where('type', 'session')->where('outcome', 'success')->where('created_at', '>=', $since)->count(); $failure = LoginEvent::where('type', 'session')->where('outcome', 'failure')->where('created_at', '>=', $since)->count(); $total = $success + $failure; return $total === 0 ? 0.0 : round(($failure / $total) * 100, 1); } private static function oauthFunnel(Carbon $since): array { $counts = OAuthAuthorizationEvent::query() ->where('created_at', '>=', $since) ->selectRaw('outcome, count(*) as count') ->groupBy('outcome') ->pluck('count', 'outcome'); $attempted = (int) ($counts['attempted'] ?? 0); $approved = (int) ($counts['approved'] ?? 0); $denied = (int) ($counts['denied'] ?? 0); return [ 'attempted' => $attempted, 'approved' => $approved, 'denied' => $denied, 'approval_rate' => $attempted === 0 ? 0.0 : round(($approved / $attempted) * 100, 1), ]; } private static function oauthFunnelByClient(Carbon $since): array { return OAuthAuthorizationEvent::query() ->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_authorization_events.client_id') ->where('oauth_authorization_events.created_at', '>=', $since) ->groupBy('oauth_clients.id', 'oauth_clients.name') ->orderByDesc('attempted') ->limit(8) ->get([ 'oauth_clients.name as client_name', DB::raw("sum(case when outcome = 'attempted' then 1 else 0 end) as attempted"), DB::raw("sum(case when outcome = 'approved' then 1 else 0 end) as approved"), DB::raw("sum(case when outcome = 'denied' then 1 else 0 end) as denied"), ]) ->map(fn ($row) => [ 'client_name' => $row->client_name, 'attempted' => (int) $row->attempted, 'approved' => (int) $row->approved, 'denied' => (int) $row->denied, ]) ->toArray(); } private static function activeTokensByClient(): array { return Token::query() ->join('oauth_clients', 'oauth_clients.id', '=', 'oauth_access_tokens.client_id') ->where('oauth_access_tokens.revoked', false) ->where('oauth_access_tokens.expires_at', '>', now()) ->groupBy('oauth_clients.id', 'oauth_clients.name') ->orderByDesc('count') ->limit(8) ->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')]) ->toArray(); } private static function scopeUsage(Carbon $since): array { $descriptions = Passport::scopes()->keyBy('id'); $counts = []; Token::query() ->where('oauth_access_tokens.created_at', '>=', $since) ->where('revoked', false) ->pluck('scopes') ->each(function ($raw) use (&$counts) { $scopes = is_array($raw) ? $raw : (json_decode($raw ?? '[]', true) ?? []); foreach ($scopes as $scope) { $counts[$scope] = ($counts[$scope] ?? 0) + 1; } }); arsort($counts); return collect($counts) ->take(8) ->map(fn ($count, $scope) => [ 'scope' => $scope, 'description' => $descriptions->get($scope)?->description ?? $scope, 'count' => $count, ]) ->values() ->toArray(); } private static function loginsByDay(Carbon $since, int $days): array { $rows = LoginEvent::query() ->selectRaw("date(created_at) as day, type, count(*) as count") ->where('outcome', 'success') ->where('created_at', '>=', $since) ->groupBy('day', 'type') ->get() ->groupBy('day'); $out = []; for ($i = 0; $i < $days; $i++) { $date = $since->copy()->addDays($i)->toDateString(); $dayRows = $rows->get($date, collect()); $out[] = [ 'date' => $date, 'session' => (int) $dayRows->firstWhere('type', 'session')?->count, 'oauth' => (int) $dayRows->firstWhere('type', 'oauth')?->count, ]; } return $out; } private static function loginsByApp(Carbon $since): array { return LoginEvent::query() ->join('oauth_clients', 'oauth_clients.id', '=', 'login_events.client_id') ->where('login_events.type', 'oauth') ->where('login_events.outcome', 'success') ->where('login_events.created_at', '>=', $since) ->groupBy('oauth_clients.id', 'oauth_clients.name') ->orderByDesc('count') ->limit(8) ->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')]) ->toArray(); } private static function topUsers(Carbon $since): array { return LoginEvent::query() ->join('users', 'users.id', '=', 'login_events.user_id') ->where('login_events.outcome', 'success') ->where('login_events.created_at', '>=', $since) ->groupBy('users.id', 'users.name', 'users.email') ->orderByDesc('count') ->limit(8) ->get(['users.name', 'users.email', DB::raw('count(*) as count')]) ->toArray(); } private static function recent(int $limit = 15): array { return LoginEvent::with(['user:id,name,email', 'client:id,name']) ->where('outcome', 'success') ->latest('created_at') ->limit($limit) ->get() ->map(fn (LoginEvent $event) => [ 'user_name' => $event->user?->name, 'user_email' => $event->user?->email, // null para logins de sesión (el frontend lo traduce como "Panel admin"/"Admin // panel" según el idioma) y para apps borradas (el frontend traduce ese caso // también) — así el backend no filtra texto sin traducir a la UI. 'client_name' => $event->type === 'session' ? null : $event->client?->name, 'type' => $event->type, 'ip_address' => $event->ip_address, 'created_at' => $event->created_at, ]) ->toArray(); } }