From 28a93ae5d160acfa8f3e3b09e275e9d2e18ef6e5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 6 Jun 2026 12:37:44 +0800 Subject: [PATCH 1/3] Refactor internal metrics API endpoints --- .../v1/DeveloperMetricsController.php | 282 +++++++++++++ .../Internal/v1/IamMetricsController.php | 370 ++++++++++++++++++ src/routes.php | 16 +- 3 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 src/Http/Controllers/Internal/v1/DeveloperMetricsController.php create mode 100644 src/Http/Controllers/Internal/v1/IamMetricsController.php diff --git a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php new file mode 100644 index 0000000..d83703c --- /dev/null +++ b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php @@ -0,0 +1,282 @@ +periods($request); + $companyUuid = session('company'); + $apiTotal = $this->apiRequests($companyUuid, $start, $end)->count(); + $apiErrors = $this->apiErrors($companyUuid, $start, $end)->count(); + $previousApiTotal = $this->apiRequests($companyUuid, $previousStart, $previousEnd)->count(); + $avgLatency = (float) $this->apiRequests($companyUuid, $start, $end)->avg('duration'); + $previousLatency = (float) $this->apiRequests($companyUuid, $previousStart, $previousEnd)->avg('duration'); + $webhookTotal = $this->webhookRequests($companyUuid, $start, $end)->count(); + $webhookSuccess = $this->webhookSuccesses($companyUuid, $start, $end)->count(); + $previousWebhookTotal = $this->webhookRequests($companyUuid, $previousStart, $previousEnd)->count(); + $previousWebhookSuccess = $this->webhookSuccesses($companyUuid, $previousStart, $previousEnd)->count(); + $webhookFailures = max(0, $webhookTotal - $webhookSuccess); + $previousWebhookFailures = max(0, $previousWebhookTotal - $previousWebhookSuccess); + $eventsTotal = ApiEvent::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end])->count(); + $previousEventsTotal = ApiEvent::where('company_uuid', $companyUuid)->whereBetween('created_at', [$previousStart, $previousEnd])->count(); + $currentApiErrorRate = $this->percent($apiErrors, $apiTotal); + $previousApiErrorRate = $this->percent($this->apiErrors($companyUuid, $previousStart, $previousEnd)->count(), $previousApiTotal); + $currentWebhookSuccessRate = $this->percent($webhookSuccess, $webhookTotal); + $previousWebhookSuccessRate = $this->percent($previousWebhookSuccess, $previousWebhookTotal); + + return response()->json([ + 'period' => $this->periodPayload($start, $end), + 'metrics' => [ + 'api_requests' => $this->metric('API Requests', $apiTotal, 'count', false, $this->deltaPercent($apiTotal, $previousApiTotal)), + 'api_error_rate' => $this->metric('API Error Rate', $currentApiErrorRate, 'percent', true, $this->deltaPercent($currentApiErrorRate, $previousApiErrorRate)), + 'avg_api_latency' => $this->metric('Avg API Latency', $this->milliseconds($avgLatency), 'duration', true, $this->deltaPercent($avgLatency, $previousLatency)), + 'webhook_success_rate' => $this->metric('Webhook Success Rate', $currentWebhookSuccessRate, 'percent', false, $this->deltaPercent($currentWebhookSuccessRate, $previousWebhookSuccessRate)), + 'active_api_keys' => $this->metric('Active API Keys', ApiCredential::where('company_uuid', $companyUuid)->whereNull('deleted_at')->count()), + 'active_webhooks' => $this->metric('Active Webhooks', WebhookEndpoint::where('company_uuid', $companyUuid)->whereNull('deleted_at')->where('status', 'enabled')->count()), + 'webhook_failures' => $this->metric('Webhook Failures', $webhookFailures, 'count', true, $this->deltaPercent($webhookFailures, $previousWebhookFailures)), + 'events_emitted' => $this->metric('Events Emitted', $eventsTotal, 'count', false, $this->deltaPercent($eventsTotal, $previousEventsTotal)), + ], + ]); + } + + public function apiTraffic(Request $request): JsonResponse + { + [$start, $end] = $this->periods($request); + $companyUuid = session('company'); + $labels = $this->labels($start, $end); + $requests = $this->dailyCounts($this->apiRequests($companyUuid, $start, $end), $labels); + $errors = $this->dailyCounts($this->apiErrors($companyUuid, $start, $end), $labels); + + return response()->json([ + 'period' => $this->periodPayload($start, $end), + 'labels' => array_keys($labels), + 'datasets' => [ + ['label' => 'Requests', 'data' => $requests], + ['label' => 'Success', 'data' => array_map(fn ($total, $failed) => max(0, $total - $failed), $requests, $errors)], + ['label' => 'Errors', 'data' => $errors], + ], + 'methods' => $this->apiRequests($companyUuid, $start, $end) + ->selectRaw('method, COUNT(*) as count') + ->groupBy('method') + ->orderByDesc('count') + ->limit(8) + ->get() + ->map(fn ($row) => ['label' => $row->method ?: 'UNKNOWN', 'value' => (int) $row->count]), + ]); + } + + public function webhookDelivery(Request $request): JsonResponse + { + [$start, $end] = $this->periods($request); + $companyUuid = session('company'); + $labels = $this->labels($start, $end); + $sent = $this->dailyCounts($this->webhookRequests($companyUuid, $start, $end), $labels); + $succeeded = $this->dailyCounts($this->webhookSuccesses($companyUuid, $start, $end), $labels); + $failed = array_map(fn ($total, $ok) => max(0, $total - $ok), $sent, $succeeded); + + return response()->json([ + 'period' => $this->periodPayload($start, $end), + 'summary' => [ + 'sent' => array_sum($sent), + 'succeeded' => array_sum($succeeded), + 'failed' => array_sum($failed), + 'success_rate' => $this->percent(array_sum($succeeded), array_sum($sent)), + 'average_attempts' => round((float) $this->webhookRequests($companyUuid, $start, $end)->avg('attempt'), 2), + 'average_duration_ms' => $this->milliseconds((float) $this->webhookRequests($companyUuid, $start, $end)->avg('duration')), + ], + 'labels' => array_keys($labels), + 'datasets' => [ + ['label' => 'Sent', 'data' => $sent], + ['label' => 'Succeeded', 'data' => $succeeded], + ['label' => 'Failed', 'data' => $failed], + ], + ]); + } + + public function credentials(): JsonResponse + { + $credentials = ApiCredential::where('company_uuid', session('company'))->whereNull('deleted_at')->get(['uuid', 'name', 'key', 'test_mode', 'last_used_at', 'expires_at']); + $now = Carbon::now(); + + return response()->json([ + 'summary' => [ + 'total' => $credentials->count(), + 'live' => $credentials->where('test_mode', false)->count(), + 'test' => $credentials->where('test_mode', true)->count(), + 'recently_used' => $credentials->filter(fn ($credential) => $credential->last_used_at && Carbon::parse($credential->last_used_at)->gte($now->copy()->subDays(30)))->count(), + 'expiring_soon' => $credentials->filter(fn ($credential) => $credential->expires_at && Carbon::parse($credential->expires_at)->between($now, $now->copy()->addDays(30)))->count(), + ], + 'items' => $credentials->sortByDesc('last_used_at')->take(8)->values()->map(fn ($credential) => [ + 'id' => $credential->uuid, + 'name' => $credential->name ?: $credential->key, + 'environment' => $credential->test_mode ? 'Test' : 'Live', + 'last_used_at' => optional($credential->last_used_at)->toISOString(), + 'expires_at' => optional($credential->expires_at)->toISOString(), + ]), + ]); + } + + public function events(Request $request): JsonResponse + { + [$start, $end] = $this->periods($request); + $companyUuid = session('company'); + $events = ApiEvent::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end]); + + return response()->json([ + 'period' => $this->periodPayload($start, $end), + 'total' => (clone $events)->count(), + 'types' => (clone $events)->selectRaw('event, COUNT(*) as count')->groupBy('event')->orderByDesc('count')->limit(10)->get()->map(fn ($row) => ['label' => $row->event ?: 'unknown', 'value' => (int) $row->count]), + 'sources' => (clone $events)->selectRaw('source, COUNT(*) as count')->groupBy('source')->orderByDesc('count')->limit(6)->get()->map(fn ($row) => ['label' => $row->source ?: 'unknown', 'value' => (int) $row->count]), + ]); + } + + public function endpointHealth(Request $request): JsonResponse + { + [$start, $end] = $this->periods($request); + $companyUuid = session('company'); + $stats = WebhookRequestLog::where('company_uuid', $companyUuid) + ->whereBetween('created_at', [$start, $end]) + ->selectRaw('webhook_uuid, COUNT(*) as total, SUM(CASE WHEN CAST(status_code AS UNSIGNED) BETWEEN 200 AND 299 THEN 1 ELSE 0 END) as succeeded, AVG(duration) as duration, MAX(created_at) as last_delivery_at') + ->groupBy('webhook_uuid') + ->get() + ->keyBy('webhook_uuid'); + + return response()->json([ + 'items' => WebhookEndpoint::where('company_uuid', $companyUuid)->whereNull('deleted_at')->orderByDesc('updated_at')->limit(20)->get(['uuid', 'url', 'status', 'mode'])->map(function ($endpoint) use ($stats) { + $row = $stats->get($endpoint->uuid); + $total = (int) ($row->total ?? 0); + $succeeded = (int) ($row->succeeded ?? 0); + + return [ + 'id' => $endpoint->uuid, + 'url' => $endpoint->url, + 'status' => $endpoint->status, + 'mode' => $endpoint->mode, + 'success_rate' => $this->percent($succeeded, $total), + 'deliveries' => $total, + 'failures' => max(0, $total - $succeeded), + 'average_duration_ms' => $this->milliseconds((float) ($row->duration ?? 0)), + 'last_delivery_at' => $row?->last_delivery_at ? Carbon::parse($row->last_delivery_at)->toISOString() : null, + ]; + }), + ]); + } + + public function activity(Request $request): JsonResponse + { + $limit = min(max((int) $request->input('limit', 12), 1), 25); + $companyUuid = session('company'); + $items = collect(); + + ApiRequestLog::where('company_uuid', $companyUuid)->orderByDesc('created_at')->limit($limit)->get(['public_id', 'method', 'path', 'status_code', 'duration', 'created_at'])->each(function ($log) use ($items) { + $items->push(['id' => $log->public_id, 'type' => 'api_request', 'label' => trim(($log->method ?: 'API') . ' /' . ltrim($log->path ?? '', '/')), 'status' => $log->status_code, 'duration_ms' => $this->milliseconds((float) $log->duration), 'created_at' => optional($log->created_at)->toISOString()]); + }); + + WebhookRequestLog::where('company_uuid', $companyUuid)->orderByDesc('created_at')->limit($limit)->get(['public_id', 'url', 'status_code', 'duration', 'created_at'])->each(function ($log) use ($items) { + $items->push(['id' => $log->public_id, 'type' => 'webhook', 'label' => $log->url, 'status' => $log->status_code, 'duration_ms' => $this->milliseconds((float) $log->duration), 'created_at' => optional($log->created_at)->toISOString()]); + }); + + ApiEvent::where('company_uuid', $companyUuid)->orderByDesc('created_at')->limit($limit)->get(['public_id', 'event', 'description', 'created_at'])->each(function ($event) use ($items) { + $items->push(['id' => $event->public_id, 'type' => 'event', 'label' => $event->description ?: $event->event, 'status' => $event->event, 'created_at' => optional($event->created_at)->toISOString()]); + }); + + return response()->json(['items' => $items->sortByDesc('created_at')->take($limit)->values()]); + } + + private function apiRequests(?string $companyUuid, Carbon $start, Carbon $end) + { + return ApiRequestLog::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end]); + } + + private function apiErrors(?string $companyUuid, Carbon $start, Carbon $end) + { + return $this->apiRequests($companyUuid, $start, $end)->whereRaw('CAST(status_code AS UNSIGNED) >= 400'); + } + + private function webhookRequests(?string $companyUuid, Carbon $start, Carbon $end) + { + return WebhookRequestLog::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end]); + } + + private function webhookSuccesses(?string $companyUuid, Carbon $start, Carbon $end) + { + return $this->webhookRequests($companyUuid, $start, $end)->whereRaw('CAST(status_code AS UNSIGNED) BETWEEN 200 AND 299'); + } + + private function periods(Request $request): array + { + $days = match ((string) $request->input('period', '30d')) { + '7d' => 7, + '90d' => 90, + '180d' => 180, + '365d' => 365, + default => 30, + }; + $end = Carbon::now()->endOfDay(); + $start = $end->copy()->subDays($days - 1)->startOfDay(); + $previousEnd = $start->copy()->subSecond(); + $previousStart = $previousEnd->copy()->subDays($days - 1)->startOfDay(); + + return [$start, $end, $previousStart, $previousEnd]; + } + + private function labels(Carbon $start, Carbon $end): array + { + $labels = []; + $cursor = $start->copy(); + while ($cursor->lte($end)) { + $labels[$cursor->format('M j')] = $cursor->toDateString(); + $cursor->addDay(); + } + + return $labels; + } + + private function dailyCounts($query, array $labels): array + { + $counts = $query->selectRaw('DATE(created_at) as day, COUNT(*) as count')->groupBy('day')->pluck('count', 'day'); + + return collect($labels)->map(fn ($date) => (int) ($counts[$date] ?? 0))->values()->all(); + } + + private function metric(string $label, mixed $value, string $format = 'count', bool $inverse = false, ?float $delta = null): array + { + return ['label' => $label, 'value' => $value, 'format' => $format, 'inverse' => $inverse, 'delta_percent' => $delta]; + } + + private function periodPayload(Carbon $start, Carbon $end): array + { + return ['start' => $start->toISOString(), 'end' => $end->toISOString()]; + } + + private function percent(int|float|null $value, int|float|null $total): int + { + return !$value || !$total ? 0 : (int) round(($value / $total) * 100); + } + + private function deltaPercent(int|float|null $current, int|float|null $previous): ?float + { + if ($previous === null || (float) $previous === 0.0) { + return null; + } + + return round(((float) $current - (float) $previous) / abs((float) $previous) * 100, 1); + } + + private function milliseconds(float $duration): int + { + return $duration <= 0 ? 0 : (int) round($duration * 1000); + } +} diff --git a/src/Http/Controllers/Internal/v1/IamMetricsController.php b/src/Http/Controllers/Internal/v1/IamMetricsController.php new file mode 100644 index 0000000..831576b --- /dev/null +++ b/src/Http/Controllers/Internal/v1/IamMetricsController.php @@ -0,0 +1,370 @@ +companyUsersQuery($companyUuid); + $totalUsers = (clone $users)->count(); + $mfaCoverage = $this->mfaCoverage($companyUuid, $totalUsers); + + return response()->json([ + 'active_users' => $this->metric('Active Users', (clone $users)->where('company_users.status', 'active')->count(), 'users'), + 'pending_invites' => $this->metric('Pending Invites', (clone $users)->where(function ($query) { + $query->where('company_users.status', 'pending')->orWhereNull('users.email_verified_at'); + })->count(), 'users'), + 'inactive_users' => $this->metric('Inactive Users', (clone $users)->where('company_users.status', 'inactive')->count(), 'users'), + 'dormant_users' => $this->metric('Dormant Users', $this->dormantUsersQuery($companyUuid)->count(), 'users', true), + 'verified_users' => $this->metric('Verified Users', (clone $users)->whereNotNull('users.email_verified_at')->count(), 'users'), + 'mfa_coverage' => $this->metric('MFA Coverage', $mfaCoverage['value'], $mfaCoverage['format'], false, ['available' => $mfaCoverage['available']]), + 'roles' => $this->metric('Roles', Role::where('company_uuid', $companyUuid)->count(), 'roles'), + 'policies' => $this->metric('Policies', Policy::where('company_uuid', $companyUuid)->count(), 'policies'), + ]); + } + + public function identityHealth(Request $request): JsonResponse + { + $companyUuid = session('company'); + $users = $this->companyUsersQuery($companyUuid); + $totalUsers = (clone $users)->count(); + $mfaCoverage = $this->mfaCoverage($companyUuid, $totalUsers); + + return response()->json([ + 'total_users' => $totalUsers, + 'status' => $this->statusCounts($companyUuid), + 'verification' => [ + 'verified' => (clone $users)->whereNotNull('users.email_verified_at')->count(), + 'unverified' => (clone $users)->whereNull('users.email_verified_at')->count(), + ], + 'mfa' => $mfaCoverage, + 'dormant' => [ + 'count' => $this->dormantUsersQuery($companyUuid)->count(), + 'threshold_days' => self::DORMANT_DAYS, + ], + ]); + } + + public function accessCoverage(Request $request): JsonResponse + { + $companyUuid = session('company'); + $companyUsers = CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->get(['uuid', 'user_uuid']); + $userUuids = $companyUsers->pluck('user_uuid'); + $companyUserUuids = $companyUsers->pluck('uuid'); + $roleUserUuids = $this->modelAssignmentUserUuids('model_has_roles', $companyUsers, $companyUserUuids); + $policyUserUuids = $this->modelAssignmentUserUuids('model_has_policies', $companyUsers, $companyUserUuids); + $directUserUuids = $this->modelAssignmentUserUuids('model_has_permissions', $companyUsers, $companyUserUuids); + $groupUserUuids = $this->groupMembershipsQuery($companyUuid)->whereIn('group_users.user_uuid', $userUuids)->pluck('group_users.user_uuid')->unique()->values(); + $total = $userUuids->count(); + $assigned = $roleUserUuids->merge($policyUserUuids)->merge($directUserUuids)->merge($groupUserUuids)->unique()->count(); + + return response()->json([ + 'total_users' => $total, + 'with_roles' => $roleUserUuids->count(), + 'with_groups' => $groupUserUuids->count(), + 'with_policies' => $policyUserUuids->count(), + 'with_direct_permissions' => $directUserUuids->count(), + 'without_assignments' => max(0, $total - $assigned), + 'coverage' => $this->percent($assigned, $total), + ]); + } + + public function privilegedAccess(Request $request): JsonResponse + { + $companyUuid = session('company'); + + $privilegedRoles = Role::where(function ($query) use ($companyUuid) { + $query->where('company_uuid', $companyUuid)->orWhereNull('company_uuid'); + }) + ->where(function ($query) { + $query->where('name', 'like', '%admin%')->orWhere('name', 'like', '%full%'); + }) + ->withCount('permissions') + ->limit(10) + ->get(['id', 'name', 'company_uuid']) + ->map(fn ($role) => [ + 'id' => $role->id, + 'name' => $role->name, + 'type' => empty($role->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', + 'permissions_count' => $role->permissions_count, + ]); + + $wildcardPolicies = Policy::where(function ($query) use ($companyUuid) { + $query->where('company_uuid', $companyUuid)->orWhereNull('company_uuid'); + }) + ->whereHas('permissions', fn ($query) => $query->where('name', 'like', '%*%')) + ->withCount('permissions') + ->limit(10) + ->get(['id', 'name', 'company_uuid', 'service']) + ->map(fn ($policy) => [ + 'id' => $policy->id, + 'name' => $policy->name, + 'service' => $policy->service, + 'type' => empty($policy->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', + 'permissions_count' => $policy->permissions_count, + ]); + + return response()->json([ + 'privileged_roles_count' => $privilegedRoles->count(), + 'wildcard_policies_count' => $wildcardPolicies->count(), + 'direct_privileged_grants' => $this->directPrivilegedGrantCount($companyUuid), + 'roles' => $privilegedRoles, + 'policies' => $wildcardPolicies, + ]); + } + + public function policySurface(Request $request): JsonResponse + { + $companyUuid = session('company'); + + $byService = Policy::where(function ($query) use ($companyUuid) { + $query->where('company_uuid', $companyUuid)->orWhereNull('company_uuid'); + }) + ->selectRaw('COALESCE(service, "core") as service, COUNT(*) as count') + ->groupBy('service') + ->orderByDesc('count') + ->get() + ->map(fn ($row) => ['label' => $row->service ?: 'core', 'value' => (int) $row->count]); + + return response()->json([ + 'total' => $byService->sum('value'), + 'organization_managed' => Policy::where('company_uuid', $companyUuid)->count(), + 'fleetbase_managed' => Policy::whereNull('company_uuid')->count(), + 'by_service' => $byService, + ]); + } + + public function groupCoverage(Request $request): JsonResponse + { + $companyUuid = session('company'); + $groups = Group::where('company_uuid', $companyUuid)->withCount('users')->get(['uuid', 'name']); + + return response()->json([ + 'total_groups' => $groups->count(), + 'empty_groups' => $groups->where('users_count', 0)->count(), + 'total_memberships' => $this->groupMembershipsQuery($companyUuid)->count(), + 'buckets' => [ + ['label' => 'Empty', 'value' => $groups->where('users_count', 0)->count()], + ['label' => '1-5 members', 'value' => $groups->filter(fn ($group) => $group->users_count >= 1 && $group->users_count <= 5)->count()], + ['label' => '6-20 members', 'value' => $groups->filter(fn ($group) => $group->users_count >= 6 && $group->users_count <= 20)->count()], + ['label' => '20+ members', 'value' => $groups->filter(fn ($group) => $group->users_count > 20)->count()], + ], + 'largest_groups' => $groups->sortByDesc('users_count')->take(6)->values()->map(fn ($group) => [ + 'name' => $group->name, + 'members' => $group->users_count, + ]), + ]); + } + + public function userLifecycle(Request $request): JsonResponse + { + [$start, $end] = $this->period($request); + $companyUuid = session('company'); + $labels = []; + $created = []; + $pending = []; + $inactive = []; + $cursor = $start->copy(); + + while ($cursor->lte($end)) { + $dayStart = $cursor->copy()->startOfDay(); + $dayEnd = $cursor->copy()->endOfDay(); + $labels[] = $cursor->format('M j'); + $created[] = (clone $this->companyUsersQuery($companyUuid))->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); + $pending[] = (clone $this->companyUsersQuery($companyUuid))->where('company_users.status', 'pending')->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); + $inactive[] = (clone $this->companyUsersQuery($companyUuid))->where('company_users.status', 'inactive')->whereBetween('company_users.updated_at', [$dayStart, $dayEnd])->count(); + $cursor->addDay(); + } + + return response()->json([ + 'labels' => $labels, + 'datasets' => [ + ['label' => 'Created', 'data' => $created], + ['label' => 'Pending', 'data' => $pending], + ['label' => 'Inactive', 'data' => $inactive], + ], + ]); + } + + public function activity(Request $request): JsonResponse + { + $limit = min(max((int) $request->input('limit', 12), 1), 25); + $types = [ + User::class, + CompanyUser::class, + Group::class, + Role::class, + Policy::class, + Permission::class, + ]; + + $items = Activity::whereIn('subject_type', $types) + ->orderByDesc('created_at') + ->limit($limit) + ->get() + ->map(fn ($activity) => [ + 'id' => $activity->id, + 'description' => $activity->description, + 'event' => $activity->event, + 'subject_type' => $activity->humanized_subject_type, + 'causer_name' => data_get($activity, 'causer.name'), + 'created_at' => optional($activity->created_at)->toISOString(), + ]); + + return response()->json(['items' => $items]); + } + + private function companyUsersQuery(string $companyUuid) + { + return CompanyUser::query() + ->join('users', 'company_users.user_uuid', '=', 'users.uuid') + ->where('company_users.company_uuid', $companyUuid) + ->whereNull('company_users.deleted_at') + ->whereNull('users.deleted_at'); + } + + private function dormantUsersQuery(string $companyUuid) + { + return $this->companyUsersQuery($companyUuid) + ->where(function ($query) { + $query->whereNull('users.last_login')->orWhere('users.last_login', '<', now()->subDays(self::DORMANT_DAYS)); + }); + } + + private function companyUserIds(string $companyUuid): Collection + { + return CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->pluck('uuid'); + } + + private function modelAssignmentUserUuids(string $table, Collection $companyUsers, Collection $modelUuids): Collection + { + if ($modelUuids->isEmpty()) { + return collect(); + } + + $assignedCompanyUserUuids = DB::table($table)->whereIn('model_uuid', $modelUuids)->distinct()->pluck('model_uuid'); + + return $companyUsers->whereIn('uuid', $assignedCompanyUserUuids)->pluck('user_uuid')->unique()->values(); + } + + private function groupMembershipsQuery(string $companyUuid) + { + return DB::table('group_users') + ->join('groups', 'group_users.group_uuid', '=', 'groups.uuid') + ->where('groups.company_uuid', $companyUuid) + ->whereNull('group_users.deleted_at') + ->whereNull('groups.deleted_at'); + } + + private function directPrivilegedGrantCount(string $companyUuid): int + { + $companyUserUuids = $this->companyUserIds($companyUuid); + if ($companyUserUuids->isEmpty()) { + return 0; + } + + return DB::table('model_has_permissions') + ->join('permissions', 'model_has_permissions.permission_id', '=', 'permissions.id') + ->whereIn('model_has_permissions.model_uuid', $companyUserUuids) + ->where(function ($query) { + $query->where('permissions.name', 'like', '%*%')->orWhere('permissions.name', 'like', '%admin%'); + }) + ->distinct('model_has_permissions.model_uuid') + ->count('model_has_permissions.model_uuid'); + } + + private function statusCounts(string $companyUuid): array + { + $counts = CompanyUser::where('company_uuid', $companyUuid) + ->whereNull('deleted_at') + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'); + + return [ + 'active' => (int) ($counts['active'] ?? 0), + 'pending' => (int) ($counts['pending'] ?? 0), + 'inactive' => (int) ($counts['inactive'] ?? 0), + ]; + } + + private function mfaCoverage(string $companyUuid, int $totalUsers): array + { + $system = Setting::where('key', 'system.2fa')->first(); + $company = Setting::where('key', 'company.' . $companyUuid . '.2fa')->first(); + $userUuids = CompanyUser::where('company_uuid', $companyUuid)->whereNull('deleted_at')->pluck('user_uuid'); + $enabledUsers = Setting::where('key', 'like', 'user.%.2fa') + ->get(['key', 'value']) + ->filter(function ($setting) use ($userUuids) { + $uuid = str_replace(['user.', '.2fa'], '', $setting->key); + + return $userUuids->contains($uuid) && data_get($setting->value, 'enabled') === true; + }) + ->count(); + + $available = $enabledUsers > 0; + + return [ + 'available' => $available, + 'enabled_users' => $enabledUsers, + 'total_users' => $totalUsers, + 'value' => $available ? $this->percent($enabledUsers, $totalUsers) : null, + 'format' => $available ? 'percent' : 'unavailable', + 'system_enabled' => (bool) data_get($system?->value, 'enabled', false), + 'system_enforced' => (bool) data_get($system?->value, 'enforced', false), + 'company_enabled' => (bool) data_get($company?->value, 'enabled', false), + 'company_enforced' => (bool) data_get($company?->value, 'enforced', false), + ]; + } + + private function metric(string $label, mixed $value, string $format = 'count', bool $inverse = false, array $extra = []): array + { + return [ + 'label' => $label, + 'value' => $value, + 'format' => $format, + 'inverse' => $inverse, + ] + $extra; + } + + private function percent(int|float|null $value, int|float|null $total): int + { + if (!$value || !$total) { + return 0; + } + + return (int) round(($value / $total) * 100); + } + + private function period(Request $request): array + { + $days = match ($request->string('period')->toString()) { + '7d' => 7, + '90d' => 90, + '180d' => 180, + '365d' => 365, + default => 30, + }; + + return [Carbon::now()->subDays($days - 1)->startOfDay(), Carbon::now()->endOfDay()]; + } +} diff --git a/src/routes.php b/src/routes.php index 233ad35..f2a991e 100644 --- a/src/routes.php +++ b/src/routes.php @@ -169,7 +169,21 @@ function ($router, $controller) { ); $router->fleetbaseRoutes('metrics', null, [], function ($router, $controller) { $router->get('iam', $controller('iam')); - $router->get('iam-dashboard', $controller('iamDashboard')); + $router->get('iam/kpis', 'IamMetricsController@kpis'); + $router->get('iam/identity-health', 'IamMetricsController@identityHealth'); + $router->get('iam/access-coverage', 'IamMetricsController@accessCoverage'); + $router->get('iam/privileged-access', 'IamMetricsController@privilegedAccess'); + $router->get('iam/policy-surface', 'IamMetricsController@policySurface'); + $router->get('iam/group-coverage', 'IamMetricsController@groupCoverage'); + $router->get('iam/user-lifecycle', 'IamMetricsController@userLifecycle'); + $router->get('iam/activity', 'IamMetricsController@activity'); + $router->get('dev/kpis', 'DeveloperMetricsController@kpis'); + $router->get('dev/api-traffic', 'DeveloperMetricsController@apiTraffic'); + $router->get('dev/webhook-delivery', 'DeveloperMetricsController@webhookDelivery'); + $router->get('dev/credentials', 'DeveloperMetricsController@credentials'); + $router->get('dev/events', 'DeveloperMetricsController@events'); + $router->get('dev/endpoint-health', 'DeveloperMetricsController@endpointHealth'); + $router->get('dev/activity', 'DeveloperMetricsController@activity'); } ); $router->fleetbaseRoutes('settings', null, [], function ($router, $controller) { From 1d590b9592725e5c5ca16ed757f859db30e9e937 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 11 Jun 2026 20:11:51 +0800 Subject: [PATCH 2/3] latest changes --- .../Internal/v1/AdminMetricsController.php | 368 ++++++++++++++++++ .../Internal/v1/ChatChannelController.php | 1 + .../v1/DeveloperMetricsController.php | 30 +- .../Internal/v1/DeveloperSearchController.php | 184 +++++++++ .../Internal/v1/IamMetricsController.php | 198 +++++++--- .../Internal/v1/IamSearchController.php | 216 ++++++++++ src/Http/Filter/CompanyFilter.php | 51 +++ src/Http/Resources/Organization.php | 15 + src/Models/Category.php | 3 +- src/Models/ChatChannel.php | 3 +- src/Models/Company.php | 8 +- src/Models/Extension.php | 3 +- src/Models/File.php | 3 +- src/Models/Group.php | 3 +- src/Models/Type.php | 3 +- src/Models/User.php | 3 +- src/Traits/HasApiModelBehavior.php | 4 + src/routes.php | 6 + tests/Unit/DeveloperSearchControllerTest.php | 12 + 19 files changed, 1029 insertions(+), 85 deletions(-) create mode 100644 src/Http/Controllers/Internal/v1/AdminMetricsController.php create mode 100644 src/Http/Controllers/Internal/v1/DeveloperSearchController.php create mode 100644 src/Http/Controllers/Internal/v1/IamSearchController.php create mode 100644 tests/Unit/DeveloperSearchControllerTest.php diff --git a/src/Http/Controllers/Internal/v1/AdminMetricsController.php b/src/Http/Controllers/Internal/v1/AdminMetricsController.php new file mode 100644 index 0000000..d8ce6d5 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/AdminMetricsController.php @@ -0,0 +1,368 @@ +periodBoundaries(); + + $metric = match ($slug) { + 'users-total' => $this->makeKpiMetric( + 'Users', + User::query()->count(), + User::where('created_at', '>=', $currentPeriodStart)->count(), + User::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + 'users' + ), + 'organizations-total' => $this->makeKpiMetric( + 'Organizations', + Company::query()->count(), + Company::where('created_at', '>=', $currentPeriodStart)->count(), + Company::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + 'building' + ), + 'active-admins' => $this->makeKpiMetric( + 'Active Admins', + User::where('type', 'admin')->where(function ($query) { + $query->whereNull('status')->orWhere('status', 'active'); + })->count(), + User::where('type', 'admin')->where('created_at', '>=', $currentPeriodStart)->count(), + User::where('type', 'admin')->whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + 'user-shield' + ), + 'organizations-attention' => $this->makeKpiMetric( + 'Pending Attention', + $this->organizationsAttentionCount(), + Company::where('created_at', '>=', $currentPeriodStart)->whereNull('onboarding_completed_at')->count(), + Company::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->whereNull('onboarding_completed_at')->count(), + 'building-circle-exclamation', + $this->organizationsAttentionCount() > 0 ? 'warning' : 'success' + ), + 'new-users' => $this->makeKpiMetric( + 'New Users', + User::where('created_at', '>=', $currentPeriodStart)->count(), + User::where('created_at', '>=', $currentPeriodStart)->count(), + User::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + 'user-plus' + ), + 'new-organizations' => $this->makeKpiMetric( + 'New Organizations', + Company::where('created_at', '>=', $currentPeriodStart)->count(), + Company::where('created_at', '>=', $currentPeriodStart)->count(), + Company::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + 'building-circle-check' + ), + 'failed-jobs' => $this->makeKpiMetric( + 'Failed Jobs', + $this->failedJobsCount(), + $this->failedJobsCount($currentPeriodStart), + $this->failedJobsCount($previousPeriodStart, $currentPeriodStart), + 'triangle-exclamation', + $this->failedJobsCount() > 0 ? 'danger' : 'success' + ), + 'suspicious-activity' => $this->makeKpiMetric( + 'Suspicious Activity', + $this->sensitiveActivityCount($currentPeriodStart), + $this->sensitiveActivityCount($currentPeriodStart), + $this->sensitiveActivityCount($previousPeriodStart, $currentPeriodStart), + 'shield-halved', + $this->sensitiveActivityCount($currentPeriodStart) > 0 ? 'warning' : 'success' + ), + default => null, + }; + + if ($metric === null) { + return response()->json(['error' => 'Unknown admin metric.'], 404); + } + + return response()->json($metric); + } + + public function widget(Request $request, string $widget): JsonResponse + { + $summary = match ($widget) { + 'system-diagnostics' => $this->systemDiagnosticsSummary(), + 'admin-activity' => $this->adminActivitySummary(), + 'organization-risk-queue' => $this->organizationRiskQueueSummary(), + 'configuration-gaps' => $this->configurationGapsSummary(), + default => null, + }; + + if ($summary === null) { + return response()->json(['error' => 'Unknown admin dashboard widget.'], 404); + } + + return response()->json($summary); + } + + public function growth(Request $request): JsonResponse + { + [$currentPeriodStart, $previousPeriodStart] = $this->periodBoundaries(); + + return response()->json([ + 'title' => 'Platform Growth Trend', + 'subtitle' => 'Current 30 days compared with the previous 30 days', + 'icon' => 'chart-line', + 'type' => 'line', + 'labels' => ['Previous 30d', 'Current 30d'], + 'datasets' => [ + [ + 'label' => 'Users', + 'data' => [ + User::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + User::where('created_at', '>=', $currentPeriodStart)->count(), + ], + 'borderColor' => '#2563eb', + 'backgroundColor' => 'rgba(37, 99, 235, 0.15)', + 'tension' => 0.35, + 'fill' => true, + ], + [ + 'label' => 'Organizations', + 'data' => [ + Company::whereBetween('created_at', [$previousPeriodStart, $currentPeriodStart])->count(), + Company::where('created_at', '>=', $currentPeriodStart)->count(), + ], + 'borderColor' => '#059669', + 'backgroundColor' => 'rgba(5, 150, 105, 0.12)', + 'tension' => 0.35, + 'fill' => true, + ], + ], + 'items' => [], + 'empty' => 'No growth data available.', + ]); + } + + private function periodBoundaries(): array + { + $now = Carbon::now(); + + return [$now->copy()->subDays(30), $now->copy()->subDays(60)]; + } + + private function makeKpiMetric(string $title, int $value, int $current, int $previous, string $icon, string $status = 'neutral'): array + { + return [ + 'title' => $title, + 'value' => $value, + 'format' => 'count', + 'delta_pct' => $this->deltaPercent($current, $previous), + 'status' => $status, + 'icon' => $icon, + 'sparkline' => [ + 'labels' => ['Previous', 'Current'], + 'data' => [$previous, $current], + ], + ]; + } + + private function deltaPercent(int $current, int $previous): int + { + if ($previous === 0) { + return $current > 0 ? 100 : 0; + } + + return (int) round((($current - $previous) / $previous) * 100); + } + + private function organizationsAttentionCount(): int + { + return Company::whereNull('onboarding_completed_at') + ->orWhereNull('owner_uuid') + ->orWhere(function ($query) { + $query->whereNotNull('status')->where('status', '!=', 'active'); + }) + ->count(); + } + + private function failedJobsCount(?Carbon $start = null, ?Carbon $end = null): int + { + if (!Schema::hasTable('failed_jobs')) { + return 0; + } + + $query = DB::table('failed_jobs'); + + if ($start && $end) { + $query->whereBetween('failed_at', [$start, $end]); + } elseif ($start) { + $query->where('failed_at', '>=', $start); + } + + return $query->count(); + } + + private function sensitiveActivityCount(?Carbon $start = null, ?Carbon $end = null): int + { + if (!Schema::hasTable(config('activitylog.table_name', 'activity_log'))) { + return 0; + } + + $query = Activity::where(function ($query) { + $query->where('description', 'like', '%impersonat%') + ->orWhere('description', 'like', '%password%') + ->orWhere('description', 'like', '%admin%') + ->orWhere('event', 'like', '%impersonat%') + ->orWhere('event', 'like', '%password%'); + }); + + if ($start && $end) { + $query->whereBetween('created_at', [$start, $end]); + } elseif ($start) { + $query->where('created_at', '>=', $start); + } + + return $query->count(); + } + + private function systemDiagnosticsSummary(): array + { + return [ + 'title' => 'System Diagnostics', + 'subtitle' => 'Core service configuration state', + 'icon' => 'heart-pulse', + 'empty' => 'No diagnostics available.', + 'items' => [ + $this->diagnosticItem('Mail', config('mail.default'), 'envelope'), + $this->diagnosticItem('Filesystem', config('filesystems.default'), 'hard-drive'), + $this->diagnosticItem('Queue', config('queue.default'), 'list-check'), + $this->diagnosticItem('Socket', config('broadcasting.default'), 'tower-broadcast'), + $this->diagnosticItem('Notifications', config('fleetbase.notifications.default_channel', 'configured'), 'bell'), + $this->diagnosticItem('Scheduler', config('schedule-monitor.enabled', true) ? 'configured' : null, 'calendar-check'), + ], + ]; + } + + private function diagnosticItem(string $title, mixed $value, string $icon): array + { + $configured = filled($value); + + return [ + 'title' => $title, + 'description' => $configured ? (string) $value : 'Not configured', + 'value' => $configured ? 'OK' : 'Missing', + 'status' => $configured ? 'success' : 'danger', + 'icon' => $icon, + ]; + } + + private function adminActivitySummary(): array + { + if (!Schema::hasTable(config('activitylog.table_name', 'activity_log'))) { + return [ + 'title' => 'Admin Activity', + 'subtitle' => 'Recent sensitive admin events', + 'icon' => 'clock-rotate-left', + 'empty' => 'Activity logging is unavailable.', + 'items' => [], + ]; + } + + $items = Activity::where(function ($query) { + $query->where('description', 'like', '%impersonat%') + ->orWhere('description', 'like', '%password%') + ->orWhere('description', 'like', '%admin%') + ->orWhere('subject_type', User::class) + ->orWhere('subject_type', Company::class); + }) + ->with(['causer']) + ->orderByDesc('created_at') + ->limit(12) + ->get() + ->map(fn ($activity) => [ + 'title' => $activity->description ?: 'Admin activity', + 'description' => trim(collect([data_get($activity, 'causer.name'), optional($activity->created_at)->diffForHumans()])->filter()->implode(' / ')), + 'value' => $activity->event, + 'status' => str_contains((string) $activity->description, 'password') ? 'warning' : 'info', + 'icon' => 'clock-rotate-left', + ]) + ->values(); + + return [ + 'title' => 'Admin Activity', + 'subtitle' => 'Recent sensitive admin events', + 'icon' => 'clock-rotate-left', + 'empty' => 'No recent sensitive admin activity.', + 'items' => $items, + ]; + } + + private function organizationRiskQueueSummary(): array + { + $items = Company::query() + ->whereNull('onboarding_completed_at') + ->orWhereNull('owner_uuid') + ->orWhere(function ($query) { + $query->whereNotNull('status')->where('status', '!=', 'active'); + }) + ->orderByDesc('created_at') + ->limit(12) + ->get() + ->map(function ($company) { + $reason = $company->owner_uuid === null ? 'Missing owner' : ($company->onboarding_completed_at === null ? 'Incomplete onboarding' : 'Status review'); + + return [ + 'title' => $company->name, + 'description' => $company->public_id ?: $company->uuid, + 'value' => $reason, + 'status' => $reason === 'Status review' ? 'danger' : 'warning', + 'icon' => 'building', + ]; + }) + ->values(); + + return [ + 'title' => 'Organization Risk Queue', + 'subtitle' => 'Organizations needing operator review', + 'icon' => 'building-shield', + 'empty' => 'No organizations currently need review.', + 'items' => $items, + ]; + } + + private function configurationGapsSummary(): array + { + $items = collect([ + $this->configGapItem('Mail driver', config('mail.default'), 'envelope'), + $this->configGapItem('Queue driver', config('queue.default'), 'list-check'), + $this->configGapItem('Filesystem disk', config('filesystems.default'), 'hard-drive'), + $this->configGapItem('Broadcast driver', config('broadcasting.default'), 'tower-broadcast'), + ])->filter()->values(); + + return [ + 'title' => 'Configuration Gaps', + 'subtitle' => 'Missing configuration that can affect operators', + 'icon' => 'screwdriver-wrench', + 'empty' => 'No configuration gaps detected.', + 'items' => $items, + ]; + } + + private function configGapItem(string $title, mixed $value, string $icon): ?array + { + if (filled($value)) { + return null; + } + + return [ + 'title' => $title, + 'description' => 'Required platform configuration is missing.', + 'value' => 'Missing', + 'status' => 'danger', + 'icon' => $icon, + ]; + } +} diff --git a/src/Http/Controllers/Internal/v1/ChatChannelController.php b/src/Http/Controllers/Internal/v1/ChatChannelController.php index a342c43..886907b 100644 --- a/src/Http/Controllers/Internal/v1/ChatChannelController.php +++ b/src/Http/Controllers/Internal/v1/ChatChannelController.php @@ -51,6 +51,7 @@ public function createRecord(Request $request) ]); } + $chatChannel->refresh(); $chatChannel->load(['participants.user', 'lastMessage']); $this->resource::wrap($this->resourceSingularlName); diff --git a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php index d83703c..6add50a 100644 --- a/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php +++ b/src/Http/Controllers/Internal/v1/DeveloperMetricsController.php @@ -39,14 +39,14 @@ public function kpis(Request $request): JsonResponse return response()->json([ 'period' => $this->periodPayload($start, $end), 'metrics' => [ - 'api_requests' => $this->metric('API Requests', $apiTotal, 'count', false, $this->deltaPercent($apiTotal, $previousApiTotal)), - 'api_error_rate' => $this->metric('API Error Rate', $currentApiErrorRate, 'percent', true, $this->deltaPercent($currentApiErrorRate, $previousApiErrorRate)), - 'avg_api_latency' => $this->metric('Avg API Latency', $this->milliseconds($avgLatency), 'duration', true, $this->deltaPercent($avgLatency, $previousLatency)), + 'api_requests' => $this->metric('API Requests', $apiTotal, 'count', false, $this->deltaPercent($apiTotal, $previousApiTotal)), + 'api_error_rate' => $this->metric('API Error Rate', $currentApiErrorRate, 'percent', true, $this->deltaPercent($currentApiErrorRate, $previousApiErrorRate)), + 'avg_api_latency' => $this->metric('Avg API Latency', $this->milliseconds($avgLatency), 'duration', true, $this->deltaPercent($avgLatency, $previousLatency)), 'webhook_success_rate' => $this->metric('Webhook Success Rate', $currentWebhookSuccessRate, 'percent', false, $this->deltaPercent($currentWebhookSuccessRate, $previousWebhookSuccessRate)), - 'active_api_keys' => $this->metric('Active API Keys', ApiCredential::where('company_uuid', $companyUuid)->whereNull('deleted_at')->count()), - 'active_webhooks' => $this->metric('Active Webhooks', WebhookEndpoint::where('company_uuid', $companyUuid)->whereNull('deleted_at')->where('status', 'enabled')->count()), - 'webhook_failures' => $this->metric('Webhook Failures', $webhookFailures, 'count', true, $this->deltaPercent($webhookFailures, $previousWebhookFailures)), - 'events_emitted' => $this->metric('Events Emitted', $eventsTotal, 'count', false, $this->deltaPercent($eventsTotal, $previousEventsTotal)), + 'active_api_keys' => $this->metric('Active API Keys', ApiCredential::where('company_uuid', $companyUuid)->whereNull('deleted_at')->count()), + 'active_webhooks' => $this->metric('Active Webhooks', WebhookEndpoint::where('company_uuid', $companyUuid)->whereNull('deleted_at')->where('status', 'enabled')->count()), + 'webhook_failures' => $this->metric('Webhook Failures', $webhookFailures, 'count', true, $this->deltaPercent($webhookFailures, $previousWebhookFailures)), + 'events_emitted' => $this->metric('Events Emitted', $eventsTotal, 'count', false, $this->deltaPercent($eventsTotal, $previousEventsTotal)), ], ]); } @@ -96,7 +96,7 @@ public function webhookDelivery(Request $request): JsonResponse 'average_attempts' => round((float) $this->webhookRequests($companyUuid, $start, $end)->avg('attempt'), 2), 'average_duration_ms' => $this->milliseconds((float) $this->webhookRequests($companyUuid, $start, $end)->avg('duration')), ], - 'labels' => array_keys($labels), + 'labels' => array_keys($labels), 'datasets' => [ ['label' => 'Sent', 'data' => $sent], ['label' => 'Succeeded', 'data' => $succeeded], @@ -135,9 +135,9 @@ public function events(Request $request): JsonResponse $events = ApiEvent::where('company_uuid', $companyUuid)->whereBetween('created_at', [$start, $end]); return response()->json([ - 'period' => $this->periodPayload($start, $end), - 'total' => (clone $events)->count(), - 'types' => (clone $events)->selectRaw('event, COUNT(*) as count')->groupBy('event')->orderByDesc('count')->limit(10)->get()->map(fn ($row) => ['label' => $row->event ?: 'unknown', 'value' => (int) $row->count]), + 'period' => $this->periodPayload($start, $end), + 'total' => (clone $events)->count(), + 'types' => (clone $events)->selectRaw('event, COUNT(*) as count')->groupBy('event')->orderByDesc('count')->limit(10)->get()->map(fn ($row) => ['label' => $row->event ?: 'unknown', 'value' => (int) $row->count]), 'sources' => (clone $events)->selectRaw('source, COUNT(*) as count')->groupBy('source')->orderByDesc('count')->limit(6)->get()->map(fn ($row) => ['label' => $row->source ?: 'unknown', 'value' => (int) $row->count]), ]); } @@ -218,10 +218,10 @@ private function webhookSuccesses(?string $companyUuid, Carbon $start, Carbon $e private function periods(Request $request): array { $days = match ((string) $request->input('period', '30d')) { - '7d' => 7, - '90d' => 90, - '180d' => 180, - '365d' => 365, + '7d' => 7, + '90d' => 90, + '180d' => 180, + '365d' => 365, default => 30, }; $end = Carbon::now()->endOfDay(); diff --git a/src/Http/Controllers/Internal/v1/DeveloperSearchController.php b/src/Http/Controllers/Internal/v1/DeveloperSearchController.php new file mode 100644 index 0000000..fb0b750 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/DeveloperSearchController.php @@ -0,0 +1,184 @@ +input('query') ?: $request->input('q'))); + $limit = max(1, min((int) $request->input('limit', 12), 24)); + + if ($query === '') { + return response()->json(['results' => []]); + } + + $types = $this->requestedTypes($request); + $perTypeLimit = max(1, (int) ceil($limit / max(count($types), 1))); + $results = collect(); + + foreach ($types as $type) { + if (!$this->canSearchType($type)) { + continue; + } + + $results = $results->merge($this->searchType($type, $query, $perTypeLimit)); + } + + return response()->json([ + 'results' => $results->take($limit)->values(), + ]); + } + + private function requestedTypes(Request $request): array + { + $types = $request->input('types', self::SEARCH_TYPES); + + if (is_string($types)) { + $types = array_filter(array_map('trim', explode(',', $types))); + } + + if (!is_array($types)) { + return self::SEARCH_TYPES; + } + + $types = array_values(array_intersect($types, self::SEARCH_TYPES)); + + return empty($types) ? self::SEARCH_TYPES : $types; + } + + private function canSearchType(string $type): bool + { + $permissions = [ + 'api_keys' => 'developers see api-key', + 'webhooks' => 'developers see webhook', + 'logs' => 'developers see log', + 'events' => 'developers see event', + ]; + + $user = Auth::getUserFromSession(); + + if ($user->isAdmin()) { + return true; + } + + return Auth::can($permissions[$type]); + } + + private function searchType(string $type, string $query, int $limit): Collection + { + return match ($type) { + 'api_keys' => $this->searchApiKeys($query, $limit), + 'webhooks' => $this->searchWebhooks($query, $limit), + 'logs' => $this->searchLogs($query, $limit), + 'events' => $this->searchEvents($query, $limit), + default => collect(), + }; + } + + private function searchApiKeys(string $query, int $limit): Collection + { + return ApiCredential::where('company_uuid', session('company')) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['name', 'key', 'uuid'], $query); + }) + ->limit($limit) + ->get(['uuid', 'name', 'key', 'test_mode']) + ->map(fn (ApiCredential $apiKey) => [ + 'label' => $apiKey->name ?: $apiKey->key, + 'description' => trim(($apiKey->test_mode ? 'Test' : 'Live') . ' API key' . ($apiKey->key ? ' - ' . $apiKey->key : '')), + 'icon' => 'key', + 'type' => 'API Key', + 'route' => 'console.developers.api-keys.index', + 'breadcrumb' => 'Developers > API Keys', + 'queryParams' => [ + 'query' => $query, + 'view_api_key' => $apiKey->uuid, + ], + ]); + } + + private function searchWebhooks(string $query, int $limit): Collection + { + return WebhookEndpoint::where('company_uuid', session('company')) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['url', 'description', 'uuid', 'status', 'mode', 'version'], $query); + }) + ->limit($limit) + ->get(['uuid', 'url', 'description', 'status', 'mode', 'version']) + ->map(fn (WebhookEndpoint $webhook) => [ + 'label' => $webhook->url, + 'description' => $webhook->description ?: trim(implode(' ', array_filter([$webhook->mode, $webhook->status, $webhook->version]))), + 'icon' => 'globe-asia', + 'type' => 'Webhook', + 'route' => 'console.developers.webhooks.view', + 'models' => [$webhook->uuid], + 'breadcrumb' => 'Developers > Webhooks', + ]); + } + + private function searchLogs(string $query, int $limit): Collection + { + return ApiRequestLog::where('company_uuid', session('company')) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['public_id', 'method', 'path', 'full_url', 'status_code', 'reason_phrase', 'ip_address', 'version', 'source'], $query); + $builder->orWhereHas('apiCredential', function (Builder $apiCredentialQuery) use ($query) { + $this->whereLike($apiCredentialQuery, ['name', 'key', 'uuid', '_key'], $query); + }); + }) + ->limit($limit) + ->get(['public_id', 'method', 'path', 'full_url', 'status_code', 'reason_phrase']) + ->map(fn (ApiRequestLog $log) => [ + 'label' => $log->public_id ?: trim($log->method . ' /' . $log->path), + 'description' => trim(implode(' ', array_filter([$log->method, $log->path ? '/' . $log->path : null, $log->status_code, $log->reason_phrase]))), + 'icon' => 'file-lines', + 'type' => 'Request Log', + 'route' => 'console.developers.logs.view', + 'models' => [$log->public_id], + 'breadcrumb' => 'Developers > Logs', + ]); + } + + private function searchEvents(string $query, int $limit): Collection + { + return ApiEvent::where('company_uuid', session('company')) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['public_id', 'event', 'source', 'description', 'method'], $query); + }) + ->limit($limit) + ->get(['public_id', 'event', 'source', 'description', 'method']) + ->map(fn (ApiEvent $event) => [ + 'label' => $event->event ?: $event->public_id, + 'description' => $event->description ?: trim(implode(' ', array_filter([$event->source, $event->method]))), + 'icon' => 'calendar-day', + 'type' => 'Event', + 'route' => 'console.developers.events.view', + 'models' => [$event->public_id], + 'breadcrumb' => 'Developers > Events', + ]); + } + + private function whereLike(Builder $builder, array $columns, string $query): void + { + $like = '%' . Str::replace(['%', '_'], ['\\%', '\\_'], $query) . '%'; + + foreach ($columns as $index => $column) { + $method = $index === 0 ? 'where' : 'orWhere'; + $builder->{$method}($column, 'like', $like); + } + } +} diff --git a/src/Http/Controllers/Internal/v1/IamMetricsController.php b/src/Http/Controllers/Internal/v1/IamMetricsController.php index 831576b..25c48de 100644 --- a/src/Http/Controllers/Internal/v1/IamMetricsController.php +++ b/src/Http/Controllers/Internal/v1/IamMetricsController.php @@ -29,16 +29,16 @@ public function kpis(Request $request): JsonResponse $mfaCoverage = $this->mfaCoverage($companyUuid, $totalUsers); return response()->json([ - 'active_users' => $this->metric('Active Users', (clone $users)->where('company_users.status', 'active')->count(), 'users'), + 'active_users' => $this->metric('Active Users', (clone $users)->where('company_users.status', 'active')->count(), 'users'), 'pending_invites' => $this->metric('Pending Invites', (clone $users)->where(function ($query) { $query->where('company_users.status', 'pending')->orWhereNull('users.email_verified_at'); })->count(), 'users'), 'inactive_users' => $this->metric('Inactive Users', (clone $users)->where('company_users.status', 'inactive')->count(), 'users'), - 'dormant_users' => $this->metric('Dormant Users', $this->dormantUsersQuery($companyUuid)->count(), 'users', true), + 'dormant_users' => $this->metric('Dormant Users', $this->dormantUsersQuery($companyUuid)->count(), 'users', true), 'verified_users' => $this->metric('Verified Users', (clone $users)->whereNotNull('users.email_verified_at')->count(), 'users'), - 'mfa_coverage' => $this->metric('MFA Coverage', $mfaCoverage['value'], $mfaCoverage['format'], false, ['available' => $mfaCoverage['available']]), - 'roles' => $this->metric('Roles', Role::where('company_uuid', $companyUuid)->count(), 'roles'), - 'policies' => $this->metric('Policies', Policy::where('company_uuid', $companyUuid)->count(), 'policies'), + 'mfa_coverage' => $this->metric('MFA Coverage', $mfaCoverage['value'], $mfaCoverage['format'], false, ['available' => $mfaCoverage['available']]), + 'roles' => $this->metric('Roles', Role::where('company_uuid', $companyUuid)->count(), 'roles'), + 'policies' => $this->metric('Policies', Policy::where('company_uuid', $companyUuid)->count(), 'policies'), ]); } @@ -50,15 +50,15 @@ public function identityHealth(Request $request): JsonResponse $mfaCoverage = $this->mfaCoverage($companyUuid, $totalUsers); return response()->json([ - 'total_users' => $totalUsers, - 'status' => $this->statusCounts($companyUuid), + 'total_users' => $totalUsers, + 'status' => $this->statusCounts($companyUuid), 'verification' => [ - 'verified' => (clone $users)->whereNotNull('users.email_verified_at')->count(), + 'verified' => (clone $users)->whereNotNull('users.email_verified_at')->count(), 'unverified' => (clone $users)->whereNull('users.email_verified_at')->count(), ], - 'mfa' => $mfaCoverage, + 'mfa' => $mfaCoverage, 'dormant' => [ - 'count' => $this->dormantUsersQuery($companyUuid)->count(), + 'count' => $this->dormantUsersQuery($companyUuid)->count(), 'threshold_days' => self::DORMANT_DAYS, ], ]); @@ -78,13 +78,13 @@ public function accessCoverage(Request $request): JsonResponse $assigned = $roleUserUuids->merge($policyUserUuids)->merge($directUserUuids)->merge($groupUserUuids)->unique()->count(); return response()->json([ - 'total_users' => $total, - 'with_roles' => $roleUserUuids->count(), - 'with_groups' => $groupUserUuids->count(), - 'with_policies' => $policyUserUuids->count(), + 'total_users' => $total, + 'with_roles' => $roleUserUuids->count(), + 'with_groups' => $groupUserUuids->count(), + 'with_policies' => $policyUserUuids->count(), 'with_direct_permissions' => $directUserUuids->count(), - 'without_assignments' => max(0, $total - $assigned), - 'coverage' => $this->percent($assigned, $total), + 'without_assignments' => max(0, $total - $assigned), + 'coverage' => $this->percent($assigned, $total), ]); } @@ -102,9 +102,9 @@ public function privilegedAccess(Request $request): JsonResponse ->limit(10) ->get(['id', 'name', 'company_uuid']) ->map(fn ($role) => [ - 'id' => $role->id, - 'name' => $role->name, - 'type' => empty($role->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', + 'id' => $role->id, + 'name' => $role->name, + 'type' => empty($role->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', 'permissions_count' => $role->permissions_count, ]); @@ -116,19 +116,19 @@ public function privilegedAccess(Request $request): JsonResponse ->limit(10) ->get(['id', 'name', 'company_uuid', 'service']) ->map(fn ($policy) => [ - 'id' => $policy->id, - 'name' => $policy->name, - 'service' => $policy->service, - 'type' => empty($policy->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', + 'id' => $policy->id, + 'name' => $policy->name, + 'service' => $policy->service, + 'type' => empty($policy->company_uuid) ? 'Fleetbase Managed' : 'Organization Managed', 'permissions_count' => $policy->permissions_count, ]); return response()->json([ - 'privileged_roles_count' => $privilegedRoles->count(), - 'wildcard_policies_count' => $wildcardPolicies->count(), + 'privileged_roles_count' => $privilegedRoles->count(), + 'wildcard_policies_count' => $wildcardPolicies->count(), 'direct_privileged_grants' => $this->directPrivilegedGrantCount($companyUuid), - 'roles' => $privilegedRoles, - 'policies' => $wildcardPolicies, + 'roles' => $privilegedRoles, + 'policies' => $wildcardPolicies, ]); } @@ -146,10 +146,10 @@ public function policySurface(Request $request): JsonResponse ->map(fn ($row) => ['label' => $row->service ?: 'core', 'value' => (int) $row->count]); return response()->json([ - 'total' => $byService->sum('value'), + 'total' => $byService->sum('value'), 'organization_managed' => Policy::where('company_uuid', $companyUuid)->count(), - 'fleetbase_managed' => Policy::whereNull('company_uuid')->count(), - 'by_service' => $byService, + 'fleetbase_managed' => Policy::whereNull('company_uuid')->count(), + 'by_service' => $byService, ]); } @@ -159,17 +159,17 @@ public function groupCoverage(Request $request): JsonResponse $groups = Group::where('company_uuid', $companyUuid)->withCount('users')->get(['uuid', 'name']); return response()->json([ - 'total_groups' => $groups->count(), - 'empty_groups' => $groups->where('users_count', 0)->count(), + 'total_groups' => $groups->count(), + 'empty_groups' => $groups->where('users_count', 0)->count(), 'total_memberships' => $this->groupMembershipsQuery($companyUuid)->count(), - 'buckets' => [ + 'buckets' => [ ['label' => 'Empty', 'value' => $groups->where('users_count', 0)->count()], ['label' => '1-5 members', 'value' => $groups->filter(fn ($group) => $group->users_count >= 1 && $group->users_count <= 5)->count()], ['label' => '6-20 members', 'value' => $groups->filter(fn ($group) => $group->users_count >= 6 && $group->users_count <= 20)->count()], ['label' => '20+ members', 'value' => $groups->filter(fn ($group) => $group->users_count > 20)->count()], ], 'largest_groups' => $groups->sortByDesc('users_count')->take(6)->values()->map(fn ($group) => [ - 'name' => $group->name, + 'name' => $group->name, 'members' => $group->users_count, ]), ]); @@ -186,17 +186,17 @@ public function userLifecycle(Request $request): JsonResponse $cursor = $start->copy(); while ($cursor->lte($end)) { - $dayStart = $cursor->copy()->startOfDay(); - $dayEnd = $cursor->copy()->endOfDay(); - $labels[] = $cursor->format('M j'); - $created[] = (clone $this->companyUsersQuery($companyUuid))->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); - $pending[] = (clone $this->companyUsersQuery($companyUuid))->where('company_users.status', 'pending')->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); + $dayStart = $cursor->copy()->startOfDay(); + $dayEnd = $cursor->copy()->endOfDay(); + $labels[] = $cursor->format('M j'); + $created[] = (clone $this->companyUsersQuery($companyUuid))->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); + $pending[] = (clone $this->companyUsersQuery($companyUuid))->where('company_users.status', 'pending')->whereBetween('company_users.created_at', [$dayStart, $dayEnd])->count(); $inactive[] = (clone $this->companyUsersQuery($companyUuid))->where('company_users.status', 'inactive')->whereBetween('company_users.updated_at', [$dayStart, $dayEnd])->count(); $cursor->addDay(); } return response()->json([ - 'labels' => $labels, + 'labels' => $labels, 'datasets' => [ ['label' => 'Created', 'data' => $created], ['label' => 'Pending', 'data' => $pending], @@ -205,6 +205,60 @@ public function userLifecycle(Request $request): JsonResponse ]); } + public function usersByTypeCreated(Request $request): JsonResponse + { + [$start, $end] = $this->period($request); + $companyUuid = session('company'); + $labels = []; + $types = (clone $this->companyUsersQuery($companyUuid)) + ->whereBetween('company_users.created_at', [$start, $end]) + ->selectRaw('COALESCE(users.type, "user") as type') + ->groupBy('type') + ->orderBy('type') + ->pluck('type') + ->values(); + + if ($types->isEmpty()) { + $types = collect(['user']); + } + + $series = $types->mapWithKeys(fn ($type) => [$type => []])->all(); + $totals = $types->mapWithKeys(fn ($type) => [$type => 0])->all(); + $cursor = $start->copy(); + + while ($cursor->lte($end)) { + $dayStart = $cursor->copy()->startOfDay(); + $dayEnd = $cursor->copy()->endOfDay(); + $labels[] = $cursor->format('M j'); + + $counts = (clone $this->companyUsersQuery($companyUuid)) + ->whereBetween('company_users.created_at', [$dayStart, $dayEnd]) + ->selectRaw('COALESCE(users.type, "user") as type, COUNT(*) as count') + ->groupBy('type') + ->pluck('count', 'type'); + + $types->each(function ($type) use (&$series, &$totals, $counts) { + $count = (int) ($counts[$type] ?? 0); + $series[$type][] = $count; + $totals[$type] += $count; + }); + + $cursor->addDay(); + } + + return response()->json([ + 'labels' => $labels, + 'datasets' => $types->values()->map(fn ($type, $index) => [ + 'label' => $this->userTypeLabel($type), + 'data' => $series[$type], + 'backgroundColor' => $this->chartColor($index, 0.72), + 'borderColor' => $this->chartColor($index), + 'borderWidth' => 1, + ]), + 'totals' => collect($totals)->mapWithKeys(fn ($count, $type) => [$this->userTypeLabel($type) => $count]), + ]); + } + public function activity(Request $request): JsonResponse { $limit = min(max((int) $request->input('limit', 12), 1), 25); @@ -222,12 +276,12 @@ public function activity(Request $request): JsonResponse ->limit($limit) ->get() ->map(fn ($activity) => [ - 'id' => $activity->id, - 'description' => $activity->description, - 'event' => $activity->event, + 'id' => $activity->id, + 'description' => $activity->description, + 'event' => $activity->event, 'subject_type' => $activity->humanized_subject_type, - 'causer_name' => data_get($activity, 'causer.name'), - 'created_at' => optional($activity->created_at)->toISOString(), + 'causer_name' => data_get($activity, 'causer.name'), + 'created_at' => optional($activity->created_at)->toISOString(), ]); return response()->json(['items' => $items]); @@ -301,8 +355,8 @@ private function statusCounts(string $companyUuid): array ->pluck('count', 'status'); return [ - 'active' => (int) ($counts['active'] ?? 0), - 'pending' => (int) ($counts['pending'] ?? 0), + 'active' => (int) ($counts['active'] ?? 0), + 'pending' => (int) ($counts['pending'] ?? 0), 'inactive' => (int) ($counts['inactive'] ?? 0), ]; } @@ -324,14 +378,14 @@ private function mfaCoverage(string $companyUuid, int $totalUsers): array $available = $enabledUsers > 0; return [ - 'available' => $available, - 'enabled_users' => $enabledUsers, - 'total_users' => $totalUsers, - 'value' => $available ? $this->percent($enabledUsers, $totalUsers) : null, - 'format' => $available ? 'percent' : 'unavailable', - 'system_enabled' => (bool) data_get($system?->value, 'enabled', false), - 'system_enforced' => (bool) data_get($system?->value, 'enforced', false), - 'company_enabled' => (bool) data_get($company?->value, 'enabled', false), + 'available' => $available, + 'enabled_users' => $enabledUsers, + 'total_users' => $totalUsers, + 'value' => $available ? $this->percent($enabledUsers, $totalUsers) : null, + 'format' => $available ? 'percent' : 'unavailable', + 'system_enabled' => (bool) data_get($system?->value, 'enabled', false), + 'system_enforced' => (bool) data_get($system?->value, 'enforced', false), + 'company_enabled' => (bool) data_get($company?->value, 'enabled', false), 'company_enforced' => (bool) data_get($company?->value, 'enforced', false), ]; } @@ -339,13 +393,33 @@ private function mfaCoverage(string $companyUuid, int $totalUsers): array private function metric(string $label, mixed $value, string $format = 'count', bool $inverse = false, array $extra = []): array { return [ - 'label' => $label, - 'value' => $value, - 'format' => $format, + 'label' => $label, + 'value' => $value, + 'format' => $format, 'inverse' => $inverse, ] + $extra; } + private function userTypeLabel(?string $type): string + { + return str($type ?: 'user')->replace(['-', '_'], ' ')->title()->toString(); + } + + private function chartColor(int $index, float $alpha = 1): string + { + $colors = [ + [37, 99, 235], + [5, 150, 105], + [245, 158, 11], + [139, 92, 246], + [244, 63, 94], + [15, 118, 110], + ]; + $color = $colors[$index % count($colors)]; + + return sprintf('rgba(%d, %d, %d, %s)', $color[0], $color[1], $color[2], rtrim(rtrim((string) $alpha, '0'), '.')); + } + private function percent(int|float|null $value, int|float|null $total): int { if (!$value || !$total) { @@ -358,10 +432,10 @@ private function percent(int|float|null $value, int|float|null $total): int private function period(Request $request): array { $days = match ($request->string('period')->toString()) { - '7d' => 7, - '90d' => 90, - '180d' => 180, - '365d' => 365, + '7d' => 7, + '90d' => 90, + '180d' => 180, + '365d' => 365, default => 30, }; diff --git a/src/Http/Controllers/Internal/v1/IamSearchController.php b/src/Http/Controllers/Internal/v1/IamSearchController.php new file mode 100644 index 0000000..ed4bcf2 --- /dev/null +++ b/src/Http/Controllers/Internal/v1/IamSearchController.php @@ -0,0 +1,216 @@ +input('query') ?: $request->input('q'))); + $limit = max(1, min((int) $request->input('limit', 12), 24)); + + if ($query === '') { + return response()->json(['results' => []]); + } + + $types = $this->requestedTypes($request); + $perTypeLimit = max(1, (int) ceil($limit / max(count($types), 1))); + $results = collect(); + + foreach ($types as $type) { + if (!$this->canSearchType($type)) { + continue; + } + + $results = $results->merge($this->searchType($type, $query, $perTypeLimit)); + } + + return response()->json([ + 'results' => $results->take($limit)->values(), + ]); + } + + private function requestedTypes(Request $request): array + { + $types = $request->input('types', self::SEARCH_TYPES); + + if (is_string($types)) { + $types = array_filter(array_map('trim', explode(',', $types))); + } + + if (!is_array($types)) { + return self::SEARCH_TYPES; + } + + $types = array_values(array_intersect($types, self::SEARCH_TYPES)); + + return empty($types) ? self::SEARCH_TYPES : $types; + } + + private function canSearchType(string $type): bool + { + $permissions = [ + 'users' => 'iam see user', + 'groups' => 'iam see group', + 'roles' => 'iam see role', + 'policies' => 'iam see policy', + ]; + + $user = Auth::getUserFromSession(); + + if ($user->isAdmin()) { + return true; + } + + return Auth::can($permissions[$type]); + } + + private function searchType(string $type, string $query, int $limit): Collection + { + return match ($type) { + 'users' => $this->searchUsers($query, $limit), + 'groups' => $this->searchGroups($query, $limit), + 'roles' => $this->searchRoles($query, $limit), + 'policies' => $this->searchPolicies($query, $limit), + default => collect(), + }; + } + + private function searchUsers(string $query, int $limit): Collection + { + $companyUuid = session('company'); + $userUuids = CompanyUser::where('company_uuid', $companyUuid) + ->whereNull('deleted_at') + ->pluck('user_uuid'); + + if ($userUuids->isEmpty()) { + return collect(); + } + + return User::whereIn('uuid', $userUuids) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['name', 'email', 'phone', 'public_id', 'uuid'], $query); + }) + ->limit($limit) + ->get(['uuid', 'public_id', 'name', 'email', 'phone']) + ->map(fn (User $user) => $this->result( + label: $user->name ?: $user->email ?: $user->public_id, + description: $user->email ?: $user->phone ?: 'IAM user', + icon: 'user', + type: 'User', + route: 'console.iam.users.index', + breadcrumb: 'IAM > Users', + query: $query, + viewParam: 'view_user', + viewId: $user->uuid + )); + } + + private function searchGroups(string $query, int $limit): Collection + { + return Group::where('company_uuid', session('company')) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['name', 'description', 'public_id', 'uuid'], $query); + }) + ->limit($limit) + ->get(['uuid', 'public_id', 'name', 'description']) + ->map(fn (Group $group) => $this->result( + label: $group->name ?: $group->public_id, + description: $group->description ?: 'IAM group', + icon: 'building', + type: 'Group', + route: 'console.iam.groups.index', + breadcrumb: 'IAM > Groups', + query: $query, + viewParam: 'view_group', + viewId: $group->uuid + )); + } + + private function searchRoles(string $query, int $limit): Collection + { + return Role::where(function (Builder $builder) { + $builder->where('company_uuid', session('company'))->orWhereNull('company_uuid'); + }) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['name', 'description', 'service', 'id'], $query); + }) + ->limit($limit) + ->get(['id', 'company_uuid', 'name', 'description', 'service']) + ->map(fn (Role $role) => $this->result( + label: $role->name, + description: $role->description ?: $role->service ?: 'IAM role', + icon: 'tag', + type: 'Role', + route: 'console.iam.roles.index', + breadcrumb: 'IAM > Roles', + query: $query, + viewParam: 'view_role', + viewId: $role->id + )); + } + + private function searchPolicies(string $query, int $limit): Collection + { + return Policy::where(function (Builder $builder) { + $builder->where('company_uuid', session('company'))->orWhereNull('company_uuid'); + }) + ->where(function (Builder $builder) use ($query) { + $this->whereLike($builder, ['name', 'description', 'service', 'id'], $query); + }) + ->limit($limit) + ->get(['id', 'company_uuid', 'name', 'description', 'service']) + ->map(fn (Policy $policy) => $this->result( + label: $policy->name, + description: $policy->description ?: $policy->service ?: 'IAM policy', + icon: 'shield', + type: 'Policy', + route: 'console.iam.policies.index', + breadcrumb: 'IAM > Policies', + query: $query, + viewParam: 'view_policy', + viewId: $policy->id + )); + } + + private function whereLike(Builder $builder, array $columns, string $query): void + { + $like = '%' . Str::replace(['%', '_'], ['\\%', '\\_'], $query) . '%'; + + foreach ($columns as $index => $column) { + $method = $index === 0 ? 'where' : 'orWhere'; + $builder->{$method}($column, 'like', $like); + } + } + + private function result(string $label, string $description, string $icon, string $type, string $route, string $breadcrumb, string $query, string $viewParam, string $viewId): array + { + return [ + 'label' => $label, + 'description' => $description, + 'icon' => $icon, + 'type' => $type, + 'route' => $route, + 'breadcrumb' => $breadcrumb, + 'queryParams' => [ + 'query' => $query, + $viewParam => $viewId, + ], + ]; + } +} diff --git a/src/Http/Filter/CompanyFilter.php b/src/Http/Filter/CompanyFilter.php index 6864957..92efbbf 100644 --- a/src/Http/Filter/CompanyFilter.php +++ b/src/Http/Filter/CompanyFilter.php @@ -41,4 +41,55 @@ public function country(?string $country) { $this->builder->searchWhere('country', $country); } + + public function status(?string $status) + { + $this->builder->searchWhere('status', $status); + } + + public function ownerEmail(?string $email) + { + $this->builder->whereHas('owner', function ($query) use ($email) { + $query->searchWhere('email', $email); + }); + } + + public function onboardingCompleted($completed) + { + if ($completed === null || $completed === '') { + return; + } + + $isCompleted = filter_var($completed, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($isCompleted === true) { + $this->builder->whereNotNull('onboarding_completed_at'); + } elseif ($isCompleted === false) { + $this->builder->whereNull('onboarding_completed_at'); + } + } + + public function billingStatus(?string $status) + { + if (!$status) { + return; + } + + if (class_exists('\\Fleetbase\\Billing\\Models\\Subscription')) { + $this->builder->whereHas('billingSubscriptions', function ($query) use ($status) { + $query->where('payment_gateway_status', $status); + }); + } elseif ($status === 'legacy') { + $this->builder->whereNotNull('plan'); + } + } + + public function createdAt(?string $date) + { + if (!$date) { + return; + } + + $this->builder->whereDate('created_at', $date); + } } diff --git a/src/Http/Resources/Organization.php b/src/Http/Resources/Organization.php index 404faff..b5a095a 100644 --- a/src/Http/Resources/Organization.php +++ b/src/Http/Resources/Organization.php @@ -8,6 +8,18 @@ class Organization extends FleetbaseResource { + protected function getBillingStatus(): ?string + { + if (!class_exists('\\Fleetbase\\Billing\\Models\\Subscription')) { + return $this->plan ? 'legacy' : null; + } + + $subscriptionClass = '\\Fleetbase\\Billing\\Models\\Subscription'; + $subscription = $subscriptionClass::where('company_uuid', $this->uuid)->latest('created_at')->first(); + + return $subscription?->payment_gateway_status ?? ($this->plan ? 'legacy' : null); + } + /** * Transform the resource into an array. * @@ -30,6 +42,8 @@ public function toArray($request) 'timezone' => $this->timezone, 'country' => $this->country, 'currency' => $this->currency, + 'plan' => $this->when(Http::isInternalRequest(), $this->plan), + 'trial_ends_at' => $this->when(Http::isInternalRequest(), $this->trial_ends_at), 'logo_url' => $this->logo_url, 'backdrop_url' => $this->backdrop_url, 'branding' => Setting::getBranding(), @@ -37,6 +51,7 @@ public function toArray($request) 'owner' => $this->owner ? new User($this->owner) : null, 'slug' => $this->slug, 'status' => $this->status, + 'billing_status' => $this->when(Http::isInternalRequest(), $this->getBillingStatus()), 'onboarding_completed' => $this->when(Http::isInternalRequest(), $this->onboarding_completed_at !== null), 'joined_at' => $this->when(Http::isInternalRequest() && $request->hasSession() && $request->session()->has('user'), function () { if ($this->resource->joined_at) { diff --git a/src/Models/Category.php b/src/Models/Category.php index 8711904..d4abe9e 100644 --- a/src/Models/Category.php +++ b/src/Models/Category.php @@ -91,7 +91,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Models/ChatChannel.php b/src/Models/ChatChannel.php index f710f5f..e3c0cae 100644 --- a/src/Models/ChatChannel.php +++ b/src/Models/ChatChannel.php @@ -87,7 +87,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** on boot make creator a participant */ diff --git a/src/Models/Company.php b/src/Models/Company.php index 5b072fb..963acbc 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -156,7 +156,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } public function creator(): BelongsTo|Builder @@ -171,6 +172,11 @@ public function owner(): BelongsTo return $this->belongsTo(User::class); } + public function billingSubscriptions(): HasMany + { + return $this->hasMany('\\Fleetbase\\Billing\\Models\\Subscription', 'company_uuid', 'uuid'); + } + public function users(): BelongsToMany { return $this->belongsToMany( diff --git a/src/Models/Extension.php b/src/Models/Extension.php index 1807afc..bf71441 100644 --- a/src/Models/Extension.php +++ b/src/Models/Extension.php @@ -139,7 +139,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Models/File.php b/src/Models/File.php index e7dff01..908bf39 100644 --- a/src/Models/File.php +++ b/src/Models/File.php @@ -91,7 +91,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('original_filename') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Models/Group.php b/src/Models/Group.php index 20b2232..4e78931 100644 --- a/src/Models/Group.php +++ b/src/Models/Group.php @@ -82,7 +82,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Models/Type.php b/src/Models/Type.php index fd8f87d..1b740aa 100644 --- a/src/Models/Type.php +++ b/src/Models/Type.php @@ -65,7 +65,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Models/User.php b/src/Models/User.php index 10f7a28..3909f39 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -211,7 +211,8 @@ public function getSlugOptions(): SlugOptions { return SlugOptions::create() ->generateSlugsFrom('name') - ->saveSlugsTo('slug'); + ->saveSlugsTo('slug') + ->doNotGenerateSlugsOnUpdate(); } /** diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 0debae0..f188cd9 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -400,6 +400,10 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo } } + if (($options['allow_slug_update'] ?? false) !== true && $this->isColumn('slug')) { + unset($input['slug']); + } + $keys = array_keys($input); foreach ($keys as $key) { diff --git a/src/routes.php b/src/routes.php index f2a991e..7e9384c 100644 --- a/src/routes.php +++ b/src/routes.php @@ -167,6 +167,8 @@ function ($router, $controller) { $router->get('export', $controller('export')); } ); + $router->get('iam/search', 'IamSearchController@search'); + $router->get('developers/search', 'DeveloperSearchController@search'); $router->fleetbaseRoutes('metrics', null, [], function ($router, $controller) { $router->get('iam', $controller('iam')); $router->get('iam/kpis', 'IamMetricsController@kpis'); @@ -176,6 +178,7 @@ function ($router, $controller) { $router->get('iam/policy-surface', 'IamMetricsController@policySurface'); $router->get('iam/group-coverage', 'IamMetricsController@groupCoverage'); $router->get('iam/user-lifecycle', 'IamMetricsController@userLifecycle'); + $router->get('iam/users-by-type-created', 'IamMetricsController@usersByTypeCreated'); $router->get('iam/activity', 'IamMetricsController@activity'); $router->get('dev/kpis', 'DeveloperMetricsController@kpis'); $router->get('dev/api-traffic', 'DeveloperMetricsController@apiTraffic'); @@ -184,6 +187,9 @@ function ($router, $controller) { $router->get('dev/events', 'DeveloperMetricsController@events'); $router->get('dev/endpoint-health', 'DeveloperMetricsController@endpointHealth'); $router->get('dev/activity', 'DeveloperMetricsController@activity'); + $router->get('admin/kpis/{slug}', 'AdminMetricsController@kpi'); + $router->get('admin/widgets/{widget}', 'AdminMetricsController@widget'); + $router->get('admin/growth', 'AdminMetricsController@growth'); } ); $router->fleetbaseRoutes('settings', null, [], function ($router, $controller) { diff --git a/tests/Unit/DeveloperSearchControllerTest.php b/tests/Unit/DeveloperSearchControllerTest.php new file mode 100644 index 0000000..40fd718 --- /dev/null +++ b/tests/Unit/DeveloperSearchControllerTest.php @@ -0,0 +1,12 @@ +search(Request::create('/developers/search', 'GET', ['query' => ' '])); + $payload = json_decode($response->getContent(), true); + + expect($payload)->toBe(['results' => []]); +}); From e72d80f6485c3addf7f122dbed455464607c9c13 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 11 Jun 2026 20:19:49 +0800 Subject: [PATCH 3/3] v1.6.51 --- composer.json | 2 +- src/Http/Filter/ActivityFilter.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f621663..d1c78ac 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.50", + "version": "1.6.51", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/src/Http/Filter/ActivityFilter.php b/src/Http/Filter/ActivityFilter.php index c12672a..05c6cf3 100644 --- a/src/Http/Filter/ActivityFilter.php +++ b/src/Http/Filter/ActivityFilter.php @@ -8,6 +8,10 @@ class ActivityFilter extends Filter { public function queryForInternal() { + if ($this->request->filled('company_uuid') && $this->request->user()?->isAdmin()) { + return; + } + $this->builder->where('company_id', $this->session->get('company')); } @@ -26,4 +30,21 @@ public function createdAt($createdAt) $this->builder->whereDate('created_at', $createdAt); } } + + public function companyUuid($companyUuid) + { + $user = $this->request->user(); + + $this->builder->where('company_id', $user?->isAdmin() ? $companyUuid : $this->session->get('company')); + } + + public function subjectId($subjectId) + { + $this->builder->where('subject_id', $subjectId); + } + + public function causerId($causerId) + { + $this->builder->where('causer_id', $causerId); + } }